Compare commits

...

133 Commits

Author SHA1 Message Date
Alexander Brown
00cfb70ad1 merge: resolve conflict in litegraphUtil.test.ts keeping both sides
Amp-Thread-ID: https://ampcode.com/threads/T-019cbbd4-2ada-7557-8a34-23a174f34f9a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 18:31:22 -08:00
Alexander Brown
5327fef29f fix: spin out workflow tab/load stability regressions (#9345)
## Summary

Spin out workflow tab/load stability fixes from the share-by-url branch
so they can merge independently and reduce regression risk.

## Changes

- **What**: Fixes duplicate tabs on repeated same-workflow loads by
making active-workflow reload idempotent in `afterLoadNewGraph`; fixes
tab flicker on save/rename by removing async detach/attach gaps in
`workflowStore`; hardens duplicate workflow path by loading before clone
and assigning a new workflow `id`.

## Review Focus

Please review the idempotency gate in `afterLoadNewGraph`
(`activeState.id === workflowData.id`) and the save/rename path update
sequencing in `workflowStore` to confirm behavior remains correct for
restoration and re-import flows.

## Screenshots (if applicable)

N/A (workflow logic and tests only)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9345-fix-spin-out-workflow-tab-load-stability-regressions-3186d73d365081fe922bdc61dcf8d8f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 17:35:18 -08:00
Comfy Org PR Bot
f5e1330228 1.41.12 (#9396)
Patch version increment to 1.41.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9396-1-41-12-31a6d73d36508145a967c7fcf5831532)
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: Christian Byrne <cbyrne@comfy.org>
2026-03-04 17:19:30 -08:00
Christian Byrne
0196a1cf54 fix: hide all painter labels in compact mode consistently (#9397)
## Summary

Add missing `v-if="!compact"` guards to painter label divs so all labels
are hidden consistently in compact mode.

## Changes

- **What**: Added `v-if="!compact"` to the Color, Hardness, Width,
Height, and Background label divs in `WidgetPainter.vue`, matching the
existing guards on Tool and Size labels.

## Review Focus

Straightforward consistency fix — all 7 label divs now use the same
compact-mode guard.

Fixes #9235

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9397-fix-hide-all-painter-labels-in-compact-mode-consistently-31a6d73d3650811a9d3af8dd290e2bca)
by [Unito](https://www.unito.io)
2026-03-04 17:09:41 -08:00
Christian Byrne
80e31a27b1 refactor: remove redundant = false defaults on boolean props (#9155)
## Summary

Remove redundant `= false` defaults from destructured `defineProps` on
boolean props, since Vue's [Boolean
casting](https://vuejs.org/guide/components/props.html#boolean-casting)
already defaults absent boolean props to `false`.

## Changes

- **What**: Remove 10 unnecessary `= false` default values across 8
components

## Review Focus

Vue compiles `defineProps<{ myBool?: boolean }>()` to `{ myBool: { type:
Boolean, required: false } }`. Boolean casting means absent boolean
props are already `false` — the explicit `= false` in JS destructuring
defaults never triggers.

Follow-up to #9150.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9155-refactor-remove-redundant-false-defaults-on-boolean-props-3116d73d36508196af0bcba53257720a)
by [Unito](https://www.unito.io)
2026-03-04 16:59:05 -08:00
Deep Mehta
0f8473db35 feat: add model type mappings for cloud custom nodes (#9392)
## Summary

Adds model-to-node backlinks in `modelToNodeStore.ts` for all
cloud-deployed custom node models that were missing mappings. Without
these, clicking "Use" on a model in the model browser throws an error.

**17 new backlinks added** covering ~340 models across deployed node
packs:

| Category | Directories | Node | Models |
|----------|-------------|------|--------|
| Vision-Language | LLM/Qwen-VL/* (12 specific paths) | AILab_QwenVL /
AILab_QwenVL_PromptEnhancer | ~186 |
| TTS | qwen-tts/* | FB_Qwen3TTSVoiceClone | ~68 |
| Video | SEEDVR2, liveportrait/*, mimicmotion, rife | various | ~33 |
| Depth | depthanything3 | DownloadAndLoadDepthAnythingV3Model | 7 |
| Segmentation | face_parsing, sam3 | various | 4 |
| Diffusers | diffusers/* (Kolors) | DownloadAndLoadKolorsModel | 16 |
| Other | clip/*, dwpose, onnx, detection, UltraShape, sharp | various |
~26 |

**Key fix:** Replaced the top-level `LLM` fallback with specific
`LLM/Qwen-VL/*` paths. The old fallback incorrectly mapped `LLM/llava-*`
models to `AILab_QwenVL`.

Models without deployed node packs (llava/HyVideo, latentsync, sam3d,
sam3dbody, inpaint, vae_approx) are excluded — those are being removed
from `supported_models.json` in Comfy-Org/cloud#2652.

## Test plan
- [ ] Verify "Use" button works for QwenVL models in model browser
- [ ] Verify "Use" button works for TTS, video, depth, segmentation
models
- [ ] Verify no `No node provider registered for category` errors for
deployed models

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-04 16:51:42 -08:00
Kelly Yang
120524faa1 feat(minimap): add node execution status visualization (#9187)
## Summary

Added visual indicators (colored borders) to the MiniMap to display the
real-time execution status (running, executed, or error) of nodes.

## Changes

- **What**: Added visual feedback to the MiniMap to show node execution
states (green for running/executed, red for errors) by integrating with
`useExecutionStore` and updating the canvas renderer.

## Review Focus

Confirmed that relying on the array `.includes()` check for
`executingNodeIds` in the data sources avoids unnecessary `Set`
allocations during frequent redraws.

## Screenshots 

<img width="540" height="446" alt="14949d48035db5c64cceb11f7f7f94a3"
src="https://github.com/user-attachments/assets/cac53a80-9882-43fd-a725-7003fe3fd21a"
/>

<img width="562" height="464" alt="7e922f54dea2cea4e6b66202d2ad0dd3"
src="https://github.com/user-attachments/assets/e178b981-3af0-417f-8e21-a706f192fabf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9187-feat-minimap-add-node-execution-status-visualization-3126d73d3650816eb7b3ca415cf6a8f1)
by [Unito](https://www.unito.io)
2026-03-04 16:43:12 -08:00
Kelly Yang
fd9e774a29 feat(ui): add copy button to read-only textarea widget on hover (#9331)
## Summary

Added a `copy-to-clipboard` button that appears when hovering over
read-only textarea widgets to improve user experience.

## Changes

- **What**: Added a copy button utilizing `useCopyToClipboard` to
[WidgetTextarea.vue](cci:7://file:///Users/kelly/Documents/comfyui/ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue:0:0-0:0)
that only displays when the widget is read-only and hovered.

## Screenshots 
<img width="670" height="498" alt="e30362fdc6792f3a955f3415f0f42afb"
src="https://github.com/user-attachments/assets/1b7ec5dc-3733-48b6-9708-6ae56926054a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9331-feat-ui-add-copy-button-to-read-only-textarea-widget-on-hover-3176d73d36508159a339d567b5c33591)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-05 09:35:38 +09:00
Christian Byrne
7b316eb9a2 feat: add statistical significance to perf report with z-score thresholds (#9305)
## Summary

Replace fixed 10%/20% perf delta thresholds with dynamic σ-based
classification using z-scores, eliminating false alarms from naturally
noisy duration metrics (10-17% CV).

## Changes

- **What**:
- Run each perf test 3× (`--repeat-each=3`) and report the mean,
reducing single-run noise
- Download last 5 successful main branch perf artifacts to compute
historical μ/σ per metric
- Replace fixed threshold flags with z-score significance: `⚠️
regression` (z>2), ` neutral/improvement`, `🔇 noisy` (CV>50%)
  - Add collapsible historical variance table (μ, σ, CV) to PR comment
- Graceful cold start: falls back to simple delta table until ≥2
historical runs exist
- New `scripts/perf-stats.ts` module with `computeStats`, `zScore`,
`classifyChange`
  - 18 unit tests for stats functions

- **CI time impact**: ~3 min → ~5-6 min (repeat-each adds ~2 min,
historical download <10s)

## Review Focus

- The `gh api` call in the new "Download historical perf baselines"
step: it queries the last 5 successful push runs on the base branch. The
`gh` CLI is available natively on `ubuntu-latest` runners and
auto-authenticates with `GITHUB_TOKEN`.
- `getHistoricalStats` averages per-run measurements before computing
cross-run σ — this is intentional since historical artifacts may also
contain repeated measurements after this change lands.
- The `noisy` classification (CV>50%) suppresses metrics like `layouts`
that hover near 0 and have meaningless percentage swings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9305-feat-add-statistical-significance-to-perf-report-with-z-score-thresholds-3156d73d3650818d9360eeafd9ae7dc1)
by [Unito](https://www.unito.io)
2026-03-04 16:16:53 -08:00
Christian Byrne
b8edb11ac1 feat: add eslint-plugin-better-tailwindcss for Tailwind v4 linting (#9245)
## Summary

Add `eslint-plugin-better-tailwindcss` to the ESLint toolchain for
Tailwind CSS v4 class linting.

## Changes

- **What**: Integrate `eslint-plugin-better-tailwindcss` (v4.3.1) with
the recommended config, pointed at the design-system CSS entry point for
v4 theme resolution. Five rules are enabled initially:
`enforce-canonical-classes`, `no-deprecated-classes`,
`no-conflicting-classes`, `no-duplicate-classes`,
`no-unnecessary-whitespace`. Three rules are disabled pending follow-up:
`no-unknown-classes` (needs PrimeIcon/custom class whitelisting),
`enforce-consistent-line-wrapping` (oxfmt conflict risk),
`enforce-consistent-class-order` (large batch change).
- **Dependencies**: `eslint-plugin-better-tailwindcss` ^4.3.1
- Fix conflicting `outline outline-1` classes in
`FormDropdownMenuActions.vue` (caught by the new
`no-conflicting-classes` rule).

## Review Focus

- Is the rule severity/enablement strategy appropriate for incremental
adoption?
- The 700 warnings (mostly `enforce-canonical-classes` and
`no-deprecated-classes`) are all auto-fixable via `eslint --fix` —
should we batch-fix them in this PR or a follow-up?

Fixes COM-15518

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9245-feat-add-eslint-plugin-better-tailwindcss-for-Tailwind-v4-linting-3136d73d365081df8a64dd55962d073f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-04 15:34:23 -08:00
AustinMroz
57a919fad2 Split selection into an inputs and outputs step (#9362)
When building an app, selecting inputs and selecting outputs are now 2
separate steps. This prevents confusion where clicking on the widget of
an output node will select that widget instead of the entire output.

<img width="1673" height="773" alt="image"
src="https://github.com/user-attachments/assets/e5994479-6fcf-4572-b58b-bf8cecfb7d55"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9362-Split-selection-into-an-inputs-and-outputs-step-3196d73d36508187b4a1e51c73f1c54c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-04 15:18:16 -08:00
Johnpaul Chiwetelu
316a05c77f fix: replace hardcoded styles with design tokens and cache StatusBadge variants (#9349)
## Summary

Replace hardcoded color and spacing values with semantic design tokens
and cache a computed variant class in StatusBadge.

## Changes

- **What**: Use Tailwind 4 CSS spacing variables in FormDropdownMenu
layout configs, replace zinc color utilities with semantic
`node-component-border` tokens in FormDropdownInput, wrap
`statusBadgeVariants()` in a `computed` for caching in StatusBadge.

## Review Focus

Straightforward token replacements and a computed caching change -- no
behavioral differences expected.

Fixes #9087
Fixes #9086
Fixes #7910

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9349-fix-replace-hardcoded-styles-with-design-tokens-and-cache-StatusBadge-variants-3186d73d36508185aae2e0753c9d1694)
by [Unito](https://www.unito.io)
2026-03-04 14:23:47 -08:00
Comfy Org PR Bot
4b70ca298a 1.41.11 (#9361)
Patch version increment to 1.41.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9361-1-41-11-3196d73d365081cc9b9ef730251b07b4)
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: Christian Byrne <cbyrne@comfy.org>
2026-03-04 14:22:12 -08:00
Benjamin Lu
1cee6272c1 fix: add run progress toggle to job history menu (#9176)
Summary
- Add hidden setting `Comfy.Queue.ShowRunProgressBar` (default `true`).
- Add `Show run progress bar` toggle to the shared `...` job history
menu (`JobHistoryActionsMenu`), placed next to `Docked Job History`.
- Use that setting to control both the inline run progress bar and the
inline summary text under it.
- Keep queue button right-click context menu focused on queue actions.
- Add/update tests for the new toggle behavior and summary visibility.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9176-fix-add-run-progress-toggle-to-job-history-menu-3116d73d365081118202d8d67a857367)
by [Unito](https://www.unito.io)
2026-03-04 14:15:11 -08:00
Christian Byrne
bcc470642f fix: cache canvas cursor style to avoid redundant DOM writes (#9171)
## Summary

Cache `canvas.style.cursor` to avoid redundant DOM writes that dirty
Firefox's style tree.

## Changes

- **What**: Add `_lastCursor` field to
`LGraphCanvas._updateCursorStyle()` — only writes `canvas.style.cursor`
when the value changes. Eliminates ~347 redundant style mutations per
profiling session.

## Review Focus

- The fix is 2 lines (cache field + comparison). The unit test validates
the caching pattern without requiring full LGraphCanvas instantiation.
- This is one of several contributors to Firefox's cascading style
recalculation freeze. Each `canvas.style.cursor` write dirties the style
tree, which is flushed during the next paint in the canvas render loop.

## Stack

2 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9171-fix-cache-canvas-cursor-style-to-avoid-redundant-DOM-writes-3116d73d36508139827fe1d644fa1bd0)
by [Unito](https://www.unito.io)
2026-03-04 14:06:31 -08:00
Johnpaul Chiwetelu
df712953a3 [fix] Replace eval() with safe math expression parser (#9263)
## Summary
Replace `eval()` in `evaluateInput()` with a custom recursive descent
math parser, eliminating a security concern and enabling the `no-eval`
lint rule.

## Changes
- **New**: `mathParser.ts` — recursive descent parser for `+`, `-`, `*`,
`/`, `%`, `()`, decimals, unary operators. Zero new dependencies.
- **Modified**: `widget.ts` — replaced `eval()` call with
`evaluateMathExpression()`, use `isFinite()` instead of `isNaN()` to
reject `Infinity`
- **Modified**: `.oxlintrc.json` — `no-eval` rule changed from `"off"`
to `"error"`
- **Tests**: 59 parser tests + 23 integration tests covering complex
expressions, edge cases, and invalid input

## Review Feedback Addressed
- Renamed `unit()` → `primary()` for clarity
- Added modulo (`%`) operator support
- Normalized negative zero to positive zero
- Added depth limit (200) for nested parentheses
- Used `isFinite()` instead of `isNaN()` to reject
`Infinity`/`-Infinity`
- Added tests for edge-case number formats, unary-after-binary
operators, modulo, depth limits, scientific/hex notation, and `Infinity`

Fixes #8032
Fixes #9272
Fixes #9273
Fixes #9274
Fixes #9275

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9263-fix-Replace-eval-with-safe-math-expression-parser-3136d73d3650812f9f8dea21d1ea4f06)
by [Unito](https://www.unito.io)
2026-03-04 14:04:37 -08:00
Johnpaul Chiwetelu
82750d629d [refactor] Type createNode options parameter (#9262)
## Summary
Narrow `CreateNodeOptions` from `Partial<Omit<LGraphNode, ...>>`
(exposing hundreds of properties/methods) to an explicit interface
listing only creation-time properties.

## Changes
- Replace `Partial<Omit<LGraphNode, 'constructor' | 'inputs' |
'outputs'>>` with explicit `CreateNodeOptions` interface containing
only: `pos`, `size`, `properties`, `flags`, `mode`, `color`, `bgcolor`,
`boxcolor`, `title`, `shape`, `inputs`, `outputs`
- Rename local `CreateNodeOptions` in `createModelNodeFromAsset.ts` to
`ModelNodeCreateOptions` to avoid collision

## Ecosystem verification
GitHub code search across ~50 repos confirms only `pos` and `outputs`
are used externally. All covered by the narrowed interface.

Fixes #9276
Fixes #4740
2026-03-04 14:01:18 -08:00
jaeone94
9e2299ca65 feat(error-groups): sort execution error cards by node execution ID (#9334)
## Summary

Sort execution error cards within each error group by their node
execution ID in ascending numeric order, ensuring consistent and
predictable display order.

## Changes

- **What**: Added `compareExecutionId` utility to
`src/types/nodeIdentification.ts` that splits node IDs on `:` and
compares segments numerically left-to-right; applied it as a sort
comparator when building `ErrorGroup.cards` in `useErrorGroups.ts`

## Review Focus

- The comparison treats missing segments as `0`, so `"1"` sorts before
`"1:20"` (subgraph nodes follow their parent); confirm this ordering
matches user expectations
- All comparisons are purely numeric — non-numeric segment values would
sort as `NaN` (treated as `0`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9334-feat-error-groups-sort-execution-error-cards-by-node-execution-ID-3176d73d365081e1b3e4e4fa8831fe16)
by [Unito](https://www.unito.io)
2026-03-04 13:59:58 -08:00
Christian Byrne
69076f35f8 test: add subgraph workflow round to performance tests (#9306)
## Summary

Add subgraph workflow performance tests to track style recalculations
and layout thrashing for nested subgraph workflows.

## Changes

- **What**: Add 3 new perf test cases (`subgraph-idle`,
`subgraph-mouse-sweep`, `subgraph-dom-widget-clipping`) that mirror the
existing default workflow tests but load the `subgraphs/nested-subgraph`
workflow. The existing perfReporter pipeline automatically picks up the
new measurements.

## Review Focus

The new tests are structurally identical to the existing 3 default
workflow tests — only the workflow loaded and measurement names differ.
No CI or config changes needed.

Fixes
https://www.notion.so/comfy-org/Implement-Add-subgraph-workflow-round-in-performance-testing-process-3156d73d365081d094efdee58215e15b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9306-test-add-subgraph-workflow-round-to-performance-tests-3156d73d36508133b85cc53a748bc75f)
by [Unito](https://www.unito.io)
2026-03-04 13:38:48 -08:00
Kelly Yang
1e86e8c4d5 [Bug] Node preview images are lost when switching between multiple workflow tabs (#9380)
## Summary

When working with multiple workflow tabs, the internal preview (image
thumbnail) of nodes like Load Image disappears after navigating away
from and back to a tab. This affects all active tabs once the switch
occurs.

## Screenshot
before


https://github.com/user-attachments/assets/99466123-37db-406f-9e17-0a9ff22311c3



after




https://github.com/user-attachments/assets/bdad0ef1-72b7-46c7-aa61-0a557688e55e

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-04 20:58:57 +00:00
pythongosssss
31276ff2a6 feat: Show empty workflow dialog when entering app builder with no nodes (#9379)
## Summary

Prompts users to load a template or return to graph when entering
builder mode on an empty workflow

## Screenshots (if applicable)

<img width="627" height="275" alt="image"
src="https://github.com/user-attachments/assets/c1a35dc3-4e8f-4abd-95b9-2f92524e8ebf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9379-feat-Show-empty-workflow-dialog-when-entering-app-builder-with-no-nodes-3196d73d36508123b643ec893cd86cac)
by [Unito](https://www.unito.io)
2026-03-04 12:15:56 -08:00
AustinMroz
f084a60708 Misc app mode fixes (#9368)
A working branch of smaller app mode fixes. Can be merged at any time
and I'll make a new branch.
- Selected inputs and outputs can now be re-ordered when clicking on
label text
- 3d outputs once again display correctly
- Some padding has been added to the side so that control buttons don't
overlap with the floating app sidebar controls
- A "Share" button placeholder has been added to the menu, but is
disabled
- Adds a workaround for canvas read_only state being disabled when
'space' is pressed.
  - This one is particularly hacky, and can be pulled out if problematic
- Fix download all only downloading the first output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9368-Misc-app-mode-fixes-3196d73d365081eab02ad1e693784707)
by [Unito](https://www.unito.io)
2026-03-04 10:14:05 -08:00
pythongosssss
c759fe517f feat: Replace BuilderExitButton with new BuilderFooterToolbar (#9378)
## Summary

Makes it easier and more obvious for users to navigate between steps

## Changes

- **What**: 
- add back/next navigation to builder footer alongside exit button
- extract shared step logic into useBuilderSteps composable

## Screenshots (if applicable)

<img width="428" height="102" alt="image"
src="https://github.com/user-attachments/assets/91b33e8f-53ae-4895-a2eb-fb1316b2b367"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9378-feat-Replace-BuilderExitButton-with-new-BuilderFooterToolbar-3196d73d3650819392efc171cf277326)
by [Unito](https://www.unito.io)
2026-03-04 09:58:59 -08:00
pythongosssss
f4ed79b133 feat: Add apps sidebar tab (#9342)
## Summary

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

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- 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)

<img width="383" height="359" alt="image"
src="https://github.com/user-attachments/assets/47905196-9db6-4a57-8cf7-384d4d37d000"
/>

<img width="335" height="281" alt="image"
src="https://github.com/user-attachments/assets/843068f3-e895-4781-bf5f-e0eb86d3387c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9342-feat-Add-apps-sidebar-tab-3176d73d3650812b822fc9cc3f17322e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-04 09:54:26 -08:00
pythongosssss
194218a9d6 fix: Prune invalid builder mappings on load (#9376)
## Summary

- extract resolveNode to reusable util
- remove mid builder pruning
- handle missing widgets with label

## Review Focus

`resolveNode` was simplified for subgraphs by calling getNodeById on
each of the subgraphs instead of searching their inner nodes manually.

## Screenshots (if applicable)

"Widget not visible"
<img width="657" height="822" alt="image"
src="https://github.com/user-attachments/assets/ab7d1e87-3210-4e54-876a-07881974b5c7"
/>
<img width="674" height="375" alt="image"
src="https://github.com/user-attachments/assets/c50ec871-d423-43d6-8e1e-7b1a362f621c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9376-fix-Prune-invalid-builder-mappings-on-load-3196d73d3650811280c2d459ed0271af)
by [Unito](https://www.unito.io)
2026-03-04 09:52:14 -08:00
pythongosssss
3e59f8e932 feat: App builder confirmation dialog after setting default view mode (#9374)
## Summary

Adds an additional dialog after setting the default view of the workflow
to let users pick their next step

## Screenshots (if applicable)

<img width="479" height="332" alt="image"
src="https://github.com/user-attachments/assets/1ea40b10-d7d3-49ff-9ea2-27b9e907c923"
/>

<img width="478" height="343" alt="image"
src="https://github.com/user-attachments/assets/21674998-5ce2-496d-97e6-ef8f2f2d7dd7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9374-feat-App-builder-confirmation-dialog-after-setting-default-view-mode-3196d73d36508192a45ee8ba0a7f74a6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 09:51:36 -08:00
Johnpaul Chiwetelu
9933d9bd11 fix: make queue button tooltip reflect current mode (#9350)
## Summary

Make queue button tooltip mode-aware so it shows the correct action text
based on whether QPOV2 is enabled.

## Changes

- **What**: Update `queueHistoryTooltipConfig` in `ComfyActionbar.vue`
to conditionally show "View Job History" (QPOV2 enabled) or
"Expand/Collapse Queue" (QPOV2 disabled) instead of always showing "View
Job History"

## Review Focus

Straightforward conditional using existing `isQueuePanelV2Enabled`
computed and existing i18n keys.

Fixes #9278

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9350-fix-make-queue-button-tooltip-reflect-current-mode-3186d73d36508122b198e5fbb0226221)
by [Unito](https://www.unito.io)
2026-03-03 22:27:36 -08:00
AustinMroz
fe8ab1d896 App mode mobile redesign (#9047)
Reworks the app mode display for mobile devices. Adds multiple bottom
tabs that can be swiped between.


![AnimateDiff_00005](https://github.com/user-attachments/assets/e1c928ff-dd52-4f4c-83a6-c351c4711e62)

To be handled in followup PRs
- Nicer error display
- Support for even smaller screens
- UX improvements for the 'Outputs' pane
  - Was postponed to minimize conflicts with non-mobile development.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9047-App-mode-mobile-redesign-30e6d73d365081388e4adea4df886522)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-03 14:18:19 -08:00
pythongosssss
68b16e3a3f feat: App mode saving rework (#9338)
## Summary

Change app mode changes to be written directly to the workflow on change
instead of requiring explicit save via builder.
Temporary: Adds `.app.json` file extension to app files for
identification since we don't currently have a way to identify them with
metadata
Removes app builder save dialog and replaces it with default mode
selection

## Changes

- **What**: 
- ensure all save locations handle app mode
- remove dirtyLinearData and flushing

- **Breaking**: 
- if people are relying on workflow names and are converting to/from app
mode in the same workflow, they will gain/lose the `.app` part of the
extension

## Screenshots (if applicable)

<img width="689" height="84" alt="image"
src="https://github.com/user-attachments/assets/335596ee-dce9-4e3a-a7b5-f0715c294e41"
/>

<img width="421" height="324" alt="image"
src="https://github.com/user-attachments/assets/ad3cd33c-e9f0-4c30-8874-d4507892fc6b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9338-feat-App-mode-saving-rework-3176d73d3650813f9ae1f6c5a234da8c)
by [Unito](https://www.unito.io)
2026-03-03 11:35:36 -08:00
Alexander Brown
ab2aaa3852 refactor: use the gradient directly instead of with a custom utility. (#9327)
## Summary

Little simpler.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9327-refactor-use-the-gradient-directly-instead-of-with-a-custom-utility-3166d73d36508179876af0ef8cea35b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-03 09:25:04 -08:00
pythongosssss
be04046ec8 App builder item - cap max width and truncate (#9335)
## Summary

Currently they overflow, this adds truncation

## Screenshots (if applicable)

<img width="325" height="459" alt="image"
src="https://github.com/user-attachments/assets/92600c14-da31-4a96-af3c-aeac928243c4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9335-App-builder-item-cap-max-width-and-truncate-3176d73d365081d1b461cf2758b04ec2)
by [Unito](https://www.unito.io)
2026-03-03 08:55:13 -08:00
pythongosssss
b18a0713db feat: App mode enter builder menu item (#9341)
## Summary

Adds enter builder menu item for easier access to app builder. 
Fixes issues with seen item tracking

## Changes

- **What**: 
- add enter builder menu item
- change non visible items to still be returned as part of the array, so
they are not incorrectly removed from the seen-items tracking
- split toggle-app-mode into two stable items

## Screenshots (if applicable)

<img width="309" height="526" alt="image"
src="https://github.com/user-attachments/assets/69affc2c-34ab-45eb-b47b-efacb8a20b99"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9341-feat-App-mode-enter-builder-menu-item-3176d73d365081a9a7e7cf1a1986354f)
by [Unito](https://www.unito.io)
2026-03-03 08:35:47 -08:00
pythongosssss
d360b2218f fix: App builder menu change "Save app" to just "Save" (#9356)
## Summary

Changes "Save app" to just "Save" as you are saving the whole workflow
as normal

## Screenshots (if applicable)

<img width="293" height="247" alt="image"
src="https://github.com/user-attachments/assets/c5aea772-12cf-4b0e-8336-8d72ef55be98"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9356-fix-App-builder-menu-change-Save-app-to-just-Save-3186d73d36508119a7c0e8e00155e0b0)
by [Unito](https://www.unito.io)
2026-03-03 08:25:48 -08:00
Terry Jia
613058e831 fix: propagate widget disabled state to Vue node components (#9321)
## Summary

Widgets with `widget.disabled = true` (e.g. display-only counters in
custom nodes) were editable in Vue node mode despite being correctly
greyed out in litegraph mode. The disabled state from the widget store
was not being merged into the options passed to Vue widget components.

## Screenshots (if applicable)
Before


https://github.com/user-attachments/assets/6957dd86-6eb9-4edb-93ee-50fc5aa5350f


After


https://github.com/user-attachments/assets/d954006f-d7e6-4e7c-9b3c-bcabed0e6260

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9321-fix-propagate-widget-disabled-state-to-Vue-node-components-3166d73d365081a7936aeabe81eb6e15)
by [Unito](https://www.unito.io)
2026-03-03 10:27:26 -05:00
Alexander Brown
0b8cc7f3ca fix: address CodeRabbit review findings for subgraph lifecycle parity
- Emit dispatchNodeConnectionChange in SubgraphInputNode stale-link fallback
- Balance beforeChange/afterChange with try/finally in SubgraphOutput.connect
- Emit disconnect event when SubgraphOutput.connect replaces existing link
- Guard connectSubgraphOutputSlot return and skip dispatch on falsy link
- Move onAfterGraphConfigured to finally block in graphConfigureUtil
- Add dispatchSlotLinkChanged to connectSubgraphOutputSlot for parity
- Guard remapProxyWidgets call with explicit map-entry check
- Harden LinkStore restore test assertions with existence checks
- Use dispatchDisconnectNodePair in disconnectOutput for consistency

Amp-Thread-ID: https://ampcode.com/threads/T-019cb1e7-a18e-712a-b8c7-4448f603c8b1
Co-authored-by: Amp <amp@ampcode.com>
2026-03-03 01:20:30 -08:00
Johnpaul Chiwetelu
16119dfcd2 fix: allow cursor positioning in painter opacity input (#9348) 2026-03-03 10:14:34 +01:00
Terry Jia
a6f1b1cf90 fix: sync subgraph name on double-click title rename (#9353)
## Summary
The Vue renderer's title editing path (NodeHeader →
useNodeEventHandlers) only updated node.title but not subgraph.name, so
the breadcrumb didn't reflect the new name when entering the subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9353-fix-sync-subgraph-name-on-double-click-title-rename-3186d73d365081e2bc54f19ecd421ac0)
by [Unito](https://www.unito.io)
2026-03-02 20:15:21 -08:00
Alexander Brown
838b168ea9 Merge branch 'main' into drjkl/he-come-to-town 2026-03-02 19:38:21 -08:00
Alexander Brown
c95d32249b fix: Custom Combo options display in Nodes 2.0 (#9324)
## Summary

Keep the value in the store instead of in the closure.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9324-fix-Custom-Combo-options-display-in-Nodes-2-0-3166d73d3650814db361c41ebdb1d222)
by [Unito](https://www.unito.io)
2026-03-02 19:23:01 -08:00
Dante
740df0470e feat: use cloud backend thumbnail resize for image previews (#9298)
## Summary

- In cloud mode, large generated images (4K, 8K+) cause browser freezing
when loaded at full resolution for preview display
- The cloud backend (ingest service) now supports a `res` query
parameter on `/api/view` that returns server-side resized JPEG (quality
80, max 512px) instead of redirecting to the full-size GCS original
- This PR adds `&res=512` to all image preview URLs in cloud mode,
reducing browser decode overhead from tens of MB to tens of KB
- Downloads still use the original resolution (no `res` param)
- No impact on localhost/desktop builds (`isCloud` compile-time
constant)

### without `?res`

302 -> png downloads
<img width="808" height="564" alt="스크린샷 2026-02-28 오후 6 53 03"
src="https://github.com/user-attachments/assets/7c1c62dd-0bc4-468d-9c74-7b98e892e126"
/>
<img width="323" height="137" alt="스크린샷 2026-02-28 오후 6 52 52"
src="https://github.com/user-attachments/assets/926aa0c4-856c-4057-96a0-d8fbd846762b"
/>

200 -> jpeg

### with `?res`
<img width="811" height="407" alt="스크린샷 2026-02-28 오후 6 51 55"
src="https://github.com/user-attachments/assets/d58d46ae-6749-4888-8bad-75344c4d868b"
/>


### Changes

- **New utility**: `getCloudResParam(filename?)` returns `&res=512` in
cloud mode for image files, empty string otherwise
- **Core stores**: `imagePreviewStore` appends `res` to node output
URLs; `queueStore.ResultItemImpl` gets a `previewUrl` getter (separates
preview from download URLs)
- **Applied to**: asset browser thumbnails, widget dropdown previews,
linear mode indicators, image compare node, background image upload

### Intentionally excluded

- Downloads (`getAssetUrl`) — need original resolution
- Mask editor — needs pixel-accurate data
- Audio/video/3D files — `res` only applies to raster images
- Execution-in-progress previews — use WebSocket blob URLs, not
`/api/view`

## Test plan

- [x] Unit tests for `getCloudResParam()` (5 tests: cloud/non-cloud,
image/non-image, undefined filename)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 5332 unit tests pass
- [x] Manual verification on cloud.comfy.org: `res=512` returns 200 with
resized JPEG; without `res` returns 302 redirect to GCS PNG original

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:56:06 +00:00
Terry Jia
dccf68ddb7 fix: improve painter cursor performance by bypassing Vue reactivity (#9339)
## Summary
Previously painter has node performance issue. 
Use direct DOM manipulation for cursor position updates instead of
reactive refs, and add will-change-transform for GPU layer promotion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9339-fix-improve-painter-cursor-performance-by-bypassing-Vue-reactivity-3176d73d365081d88b23d26e774cebf5)
by [Unito](https://www.unito.io)
2026-03-02 21:18:48 -05:00
Terry Jia
117448fba4 fix: stop pointer events on audio widgets to prevent node drag (#9329)
## Summary

Audio player and record widgets were missing @pointerdown.stop, causing
node drag when interacting with the timeline or controls.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/061a9ad2-0cc2-45f8-aea0-d45e3a2912b9


after


https://github.com/user-attachments/assets/a510c50a-65b8-4944-9480-b53cbe61c7da

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9329-fix-stop-pointer-events-on-audio-widgets-to-prevent-node-drag-3176d73d36508140b236c61e83954f5c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-02 20:43:25 -05:00
Terry Jia
da77227cf2 fix: clear combo widget value when removing image preview (#9323)
## Summary
The X button on image preview in VueNodes mode only cleared the stored
outputs but left the combo widget value intact, causing the old image to
persist across workflow runs and page refreshes.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/e2146ed1-5d79-41d6-946c-b30667ffac6a

After


https://github.com/user-attachments/assets/359b81fa-acc9-4711-9cee-62c230086f0c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9323-fix-clear-combo-widget-value-when-removing-image-preview-3166d73d3650816db867eba49b8aeb6c)
by [Unito](https://www.unito.io)
2026-03-02 20:26:44 -05:00
Comfy Org PR Bot
4868e6003f 1.41.10 (#9343)
Patch version increment to 1.41.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9343-1-41-10-3186d73d3650814d9077c68cc06131ea)
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-03-02 16:25:51 -08:00
pythongosssss
9b05d7cbb7 App mode output history UX improvements (#9285)
## Summary
- replace reka ui list with normal elements due to rekas aggressive
autoscrolling and event blocking
- rework layout to fix in progress items outside scrollable area
- extract feedback component
- avoid scroll position changing when adding new items
- add left/right keyboard navigation

## Screenshots (if applicable)
Showing fixed active items at start
<img width="1292" height="101" alt="image"
src="https://github.com/user-attachments/assets/dcd3215c-ac09-4081-b483-8631d17ca6bf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9285-App-mode-output-history-UX-improvements-3146d73d3650819a9f97edb41db975cc)
by [Unito](https://www.unito.io)
2026-03-02 14:46:45 -08:00
Terry Jia
74626d65d3 fix: use widget.options.hidden to hide painter widgets in Vue renderer (#9337)
## Summary

The Vue node renderer checks widget.options.hidden, not widget.hidden.
This was previously masked by the backend sending hidden: true via
extra_dict, but is now needed as the backend switches to io.Color.Input.

BE change is in https://github.com/Comfy-Org/ComfyUI/pull/12294

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9337-fix-use-widget-options-hidden-to-hide-painter-widgets-in-Vue-renderer-3176d73d3650815cad4beb8f9f35f7e6)
by [Unito](https://www.unito.io)
2026-03-02 14:29:21 -08:00
pythongosssss
31a4dce5d4 Add enterAppBuilder method for skipping arrange mode (#9310)
## Summary

When already in app mode and entering builder with outputs defined, skip
the select step and go straight to arrange

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9310-Add-enterAppBuilder-method-for-skipping-arrange-mode-3156d73d36508101903ff434a2a1ac08)
by [Unito](https://www.unito.io)
2026-03-02 11:10:48 -08:00
pythongosssss
0d7dc15916 App mode output feed to only show current session results for outputs defined in the app (#9307)
## Summary

Updates app mode to only show images from:
- the workflow that generated the image
- in the current session
- for the outputs selected in the builder

## Changes

- **What**: 
- adds new mapping of jobid -> workflow path [cant use id here as it is
not guaranteed unique], capped at 4k entries
- fix bug where executing a workflow then quickly switching tabs
associated incorrect workflow
- add missing output history tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9307-App-mode-output-feed-to-only-show-current-session-results-for-outputs-defined-in-the-app-3156d73d36508142b4bbca3f938fc5c2)
by [Unito](https://www.unito.io)
2026-03-02 11:10:20 -08:00
AustinMroz
1dd789fa54 Support selection of app inputs and outputs from vue mode (#9259)
- The input and output indicators are now plugged directly into the
`LGraphNode.vue` template. Care was taken to make implementation to have
low cost for performance and complexity when not in app mode setup.
- Context menu event handlers are added to each widget in vue mode
instead of resolving the target widget of an event
- Swap the nodeId passed by `useGraphNodeManager` to not include the
locator id. This id was never used and was incorrect since it didn't
resolve across nested subgraphs.
- Continued bug fixes for app mode as a whole.

Known issue: There is disparity of nodeId between litegraph (which
references the widget in the root graph) and vue (which promotes the
original widget). Efforts to reconcile are ongoing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9259-Support-selection-app-inputs-and-outputs-from-vue-mode-3136d73d365081ae8e56e35bf6322409)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-02 09:49:21 -08:00
Comfy Org PR Bot
84d7aa0fd9 1.41.9 (#9312)
Patch version increment to 1.41.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-01 20:19:10 -08:00
Alexander Brown
7b41b03af2 refactor: simplify LGraph with L3, L4, L7 remediation items
- L7: Extract resolveCanonicalSlotName and normalizeLegacySlotIdentity to utils/slotIdentity.ts (-45 lines from LGraph.ts)

- L3: Inline 4 trivial subgraphBoundaryAdapter wrappers using existing LLink getters (-26 lines from subgraphUtils.ts)

- L4: Merge hasLegacyLinkInputSlotMismatch into fixLinkInputSlots single-pass (-17 lines, eliminates double traversal)

Amp-Thread-ID: https://ampcode.com/threads/T-019ca83b-1182-77df-b270-4703bb00cf45
Co-authored-by: Amp <amp@ampcode.com>
2026-03-01 10:45:27 -08:00
Christian Byrne
59c3215296 fix: skip CodeRabbit reviews on bot and release PRs (#9279)
## Problem

CodeRabbit is reviewing release and backport PRs created by bots (e.g.
[#9264](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9264)),
leaving unnecessary review comments.

## Solution

Add ignore rules to `.coderabbit.yaml`:

- **`ignore_usernames`**: `comfy-pr-bot`, `github-actions` — skips
reviews on PRs authored by these bot accounts
- **`ignore_title_keywords`**: `[release]`, `[backport` — skips reviews
on release and backport PRs by title

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9279-fix-skip-CodeRabbit-reviews-on-bot-and-release-PRs-3146d73d3650814c9ebae0d08acbafd6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-28 23:29:21 -08:00
Alexander Brown
2cc3997fc1 refactor: PR #9247 should-fix items S1–S11
- S1: Extract _createAndRegisterLink helper from 3 connect methods
- S2: Remove dead SubgraphInputNode.connectSlots (unregistered LLinks)
- S3: Document that LGraph.connectSlots callers dispatch callbacks
- S4: Add dispatchSlotLinkChanged to connectSubgraphInputSlot
- S5: Document _version increment contract on disconnect paths
- S6: Add backwards-compat comments at 3 behavioral change sites
- S7: Gate SubgraphOutput.disconnect deprecation warning with once flag
- S8: Fix splitPositionables instanceof ordering (subclass before base)
- S9: Add explicit return type to mapReroutes
- S10: Add JSDoc to LinkStoreTopology about mutable backing maps
- S11: Document dispatch ordering asymmetry in event dispatcher

Amp-Thread-ID: https://ampcode.com/threads/T-019ca787-c4d4-755f-b63a-4d814ff46e2c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 23:05:19 -08:00
Hunter
589f58f916 feat: add ever-present upgrade button for free-tier users (#9315)
## Summary

Add persistent upgrade CTAs for free-tier users: a topbar button and
"Upgrade to add credits" replacing "Add Credits" in popovers and
settings panels.

## Changes

- **What**:
- New `TopbarSubscribeButton` component in both GraphCanvas and
LinearView topbars, visible only to free-tier users
- Profile popover (legacy + workspace): free-tier users see "Upgrade to
add credits" instead of "Add Credits", linking directly to the pricing
table
- Manage Plan settings (legacy + workspace): same replacement —
free-tier users see "Upgrade to add credits" instead of "Add Credits"
- Paid-tier users retain the original "Add Credits" behavior in all
locations
  - All upgrade buttons go directly to the pricing table (one-step flow)

## Review Focus

- The `isFreeTier` conditional gating on the buttons — ensure free-tier
users see upgrade CTAs and paid users see normal Add Credits
- Layout in Manage Plan panels uses `flex flex-col gap-3` to stack the
upgrade button below the usage history link instead of side-by-side

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9315-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081228cdfe6a67fec6aec)
by [Unito](https://www.unito.io)
2026-02-28 20:07:12 -08:00
Alexander Brown
8a3c647261 refactor: simplify SafeWidgetData and useGraphNodeManager
- Remove redundant storeNodeId and storeName from SafeWidgetData
- Extract buildSlotMetadata helper to deduplicate slot metadata logic
- Simplify normalizeWidgetValue to collapse identity branches
- Collapse duplicated flags.* property handlers into single case
- Merge color/bgcolor handlers with computed property key
- Remove redundant syncWithGraph (existing-nodes loop covers it)
- Inline trigger dispatch, remove intermediate triggerHandlers map
- Avoid duplicate extractWidgetDisplayOptions calls in common path
- Remove unused LGraphTriggerAction import

Amp-Thread-ID: https://ampcode.com/threads/T-019ca721-4ac5-730e-a526-6c71e474e766
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 19:51:21 -08:00
Alexander Brown
f0ca965892 fix: address must-fix review items for link topology PR
- Fix copy-paste error in SubgraphOutput type error message
- Add linkStore.clearGraph() call in LGraph.clear() to prevent memory leak
- Cache useLinkStore() reference on LGraph instance for hot-path perf
- Use frozen EMPTY_TOPOLOGY sentinel instead of allocating per miss
- Make canonicalName required in SubgraphEventMap (always provided)
- Replace deprecated links[linkId] with links.get() in SubgraphNode
- Document connectSlots non-nullable return contract

Amp-Thread-ID: https://ampcode.com/threads/T-019ca6ba-5e48-707b-8c81-3eda7f6bc9e2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 18:01:05 -08:00
Alexander Brown
767f19f0c3 fix: replace graph.linkStore with raw map lookups in changeTracker test
graph.linkStore was removed; use graph.links, graph.floatingLinks, and graph.reroutes maps directly to verify store-backed accessor parity.

Amp-Thread-ID: https://ampcode.com/threads/T-019ca6ac-9a9e-7139-8b31-dee2bec82341
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 15:57:21 -08:00
Hunter
7c8a548798 feat: add cloud frontend build dispatch workflow (#9308)
## Summary

Adds `.github/workflows/cloud-dispatch-build.yaml` — fires a
`repository_dispatch` event (`frontend-asset-build`) to
`Comfy-Org/cloud` on push to `cloud/*` branches and `main`.

The cloud repo handles the actual build, GCS upload, and secret
management (Sentry, Algolia, GCS creds). This is fire-and-forget.

## Changes

- New workflow: `cloud-dispatch-build.yaml`
- Trigger: `push` to `cloud/*` and `main` only
- Payload: `ref` (commit SHA) + `branch` (branch name), built with `jq`
to prevent injection
- SHA-pinned `peter-evans/repository-dispatch@v4.0.1`
- Hardened: `permissions: {}`, fork guard (`if: github.repository ==
'Comfy-Org/ComfyUI_frontend'`), concurrency to avoid dispatch storms
- `cloud-deploy-frontend.yaml` left unchanged (still needed during
migration)

## Setup Required

A repository secret `CLOUD_DISPATCH_TOKEN` must be configured — see PR
description comments.

## Part of

Frontend separate deploy prep (Task 1.3)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9308-feat-add-cloud-frontend-build-dispatch-workflow-3156d73d36508164a515eb968f6c5d79)
by [Unito](https://www.unito.io)
2026-02-28 17:59:19 -05:00
Alexander Brown
d6e05bd11a fix: use Map lookup for subgraph output disconnect
- Replace deprecated indexed link access with Map#get in SubgraphOutput.disconnect

- Add behavior-focused regression coverage for idempotent output disconnect

Amp-Thread-ID: https://ampcode.com/threads/T-019ca639-cde7-744d-b426-6ea21b2159e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 14:29:59 -08:00
Alexander Brown
0a940d62b9 Merge origin/main into drjkl/he-come-to-town
Amp-Thread-ID: https://ampcode.com/threads/T-019ca639-cde7-744d-b426-6ea21b2159e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 14:13:51 -08:00
Alexander Brown
779694cfc8 fix: stabilize nested subgraph promoted widget resolution (#9282)
Fix multiple issues with promoted widget resolution in nested subgraphs,
ensuring correct value propagation, slot matching, and rendering for
deeply nested promoted widgets.

- **What**: Stabilize nested subgraph promoted widget resolution chain
- Use deep source keys for promoted widget values in Vue rendering mode
- Resolve effective widget options from the source widget instead of the
promoted view
  - Stabilize slot resolution for nested promoted widgets
  - Preserve combo value rendering for promoted subgraph widgets
- Prevent subgraph definition deletion while other nodes still reference
the same type
  - Clean up unused exported resolution types

- `resolveConcretePromotedWidget.ts` — new recursive resolution logic
for deeply nested promoted widgets
- `useGraphNodeManager.ts` — option extraction now uses
`effectiveWidget` for promoted widgets
- `SubgraphNode.ts` — unpack no longer force-deletes definitions
referenced by other nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-28 14:12:45 -08:00
pythongosssss
6464d14fe8 Add indicator circle when new unseen menu items are available (#9220)
## Summary

Adds a little indicator circle when new workflow menu items are added
that the user has not seen

## Changes

- **What**: Adds a hidden setting to track menu items flagged as new
that have been seen

## Screenshots (if applicable)

<img width="164" height="120" alt="image"
src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b)
by [Unito](https://www.unito.io)
2026-02-28 14:06:48 -08:00
Terry Jia
2182375e5e feat: wrap CURVE widget value with typed format (#9294)
## Summary
Send CURVE values as { __type: 'CURVE', value: [...] } instead of {
__value__: [...] } to avoid ambiguity with link detection and enable
external tools to identify the data type.

change requested by @guill

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9294-feat-wrap-CURVE-widget-value-with-typed-format-3156d73d365081bf8e5de59527e2d3ce)
by [Unito](https://www.unito.io)
2026-02-28 14:06:45 -08:00
Christian Byrne
e0cc4d93da feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 14:06:42 -08:00
jaeone94
e2ad7a58a9 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 14:06:39 -08:00
jaeone94
1917804190 fix: node replacement fails after execution and modal sync (#9269)
## Summary

Fixes two bugs in the node replacement flow: placeholder detection
failing after workflow execution or pack reinstallation, and missing UI
sync in the Errors Tab when replacements are applied from the modal
dialog.

## Changes

- **Placeholder detection**: Node placeholder detection now matches
against `targetTypes` (derived from the replaceable node list built at
workflow load time) instead of relying on `has_errors` flag or
`registered_node_types` lookup. This ensures replacement works reliably
after execution (where `has_errors` gets cleared) and after pack
reinstallation (where the type becomes registered).
- **Modal → Errors Tab sync**: Added
`executionErrorStore.removeMissingNodesByType()` call in
`MissingNodesContent.vue` after replacement, so the Errors Tab reflects
changes immediately without requiring a page reload.

## Review Focus

- `collectAllNodes` predicate change in `useNodeReplacement.ts`: now
uses `targetTypes.has(originalType)` to find nodes by their original
serialized type. This is independent of runtime state like `has_errors`
or `registered_node_types`.
- `executionErrorStore.removeMissingNodesByType` call timing in
`MissingNodesContent.vue` — runs synchronously after
`replaceNodesInPlace` resolves, before auto-close logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9269-fix-node-replacement-fails-after-execution-and-modal-sync-3146d73d365081218398c961639b450f)
by [Unito](https://www.unito.io)
2026-02-28 14:06:37 -08:00
Comfy Org PR Bot
ab59e373fa 1.41.8 (#9288)
Patch version increment to 1.41.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9288-1-41-8-3156d73d3650817ca737ced3e08d8c86)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 14:06:34 -08:00
Christian Byrne
237e210962 fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172)
## Summary

Pre-rasterize the SubgraphNode SVG icon to a bitmap canvas to eliminate
Firefox's per-frame SVG style processing.

## Changes

- **What**: Add `getWorkflowBitmap()` that lazily rasterizes the
`data:image/svg+xml` workflow icon to an `HTMLCanvasElement` (16×16) on
first use. `SubgraphNode.drawTitleBox()` draws the cached bitmap instead
of the raw SVG.

## Review Focus

- Firefox re-processes SVG internal stylesheets (`stroke`,
`stroke-linecap`, `stroke-width`) every time `ctx.drawImage(svgImage)`
is called. Chrome caches the rasterization. This happens on every frame
for every visible SubgraphNode.
- Reporter confirmed strong subgraph correlation: "it may be happening
in the default workflow with subgraph" / "didn't seem to happen just
using manually wired up diffusion loader, clip, sampler, etc."
- Falls back to the raw SVG Image if not yet loaded or if
`getContext('2d')` returns null.

## Stack

3 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9172-fix-pre-rasterize-SubgraphNode-SVG-icon-to-bitmap-canvas-3116d73d365081babf17cf0848d37269)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-28 14:06:33 -08:00
AustinMroz
13040e700d Fix essentials nodes not being marked core (#9287)
In adding an essentials cateogory for nodes, #8987 introduced a
regression where core nodes which are also essential are marked as being
from a `nodes` custom node instead of being marked core. Since the
essentials designation should pre-empt core and custom nodes can choose
to mark themself as essential, the getter for `isCoreNode` is updated to
instead repeat the existing check for if a node is core.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1b8bf80-d072-409a-a0f9-4837e1d11767"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/14ff525b-9833-4e73-888f-791aff6cf531"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9287-Fix-essentials-nodes-not-being-marked-core-3146d73d365081fca2a0f8bdc2baf01a)
by [Unito](https://www.unito.io)
2026-02-28 14:06:31 -08:00
pythongosssss
bd126698f7 App builder exit updates (#9218)
## Summary

- remove exit builder button from right panel
- add builder exit button to bottom of canvas
- add builder menu with save & exit in top left

## Screenshots (if applicable)

<img width="1544" height="998" alt="image"
src="https://github.com/user-attachments/assets/f5deadc5-2bf5-4729-b644-2b6a815b9975"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9218-App-builder-exit-updates-3126d73d365081a0bf1adf92e1171060)
by [Unito](https://www.unito.io)
2026-02-28 14:06:29 -08:00
pythongosssss
de8be5c14a App mode - discard slow preview messages to prevent overwriting output image (#9261)
## Summary

Prevent latent previews received after the job/node has already finished
processing overwriting the actual output display

## Changes

- **What**: 
- updates job preview store to also track which node the preview was for
- updates linear progress tracking to store executed nodes enabling
skipping previews of these

## Review Focus

<!-- 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)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9261-App-mode-discard-slow-preview-messages-to-prevent-overwriting-output-image-3136d73d3650817884c2ce2ff5993b9e)
by [Unito](https://www.unito.io)
2026-02-28 14:06:28 -08:00
pythongosssss
9d4ae0ddca Render app builder in arrange mode (#9260)
## Summary

Adds app builder in arrange/preview mode with re-orderable widgets,
maintaining size (as much as possible) between the select + preview
steps

## Changes

- **What**: 
- Extract sidebar size constants for sharing between canvas splitter +
app mode
- Add widget list using DraggableList and render inert WidgetItems

## Screenshots (if applicable)

<img width="1391" height="923" alt="image"
src="https://github.com/user-attachments/assets/3e17eafe-db1e-40a3-83b5-15a7d0672909"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9260-Render-app-builder-in-arrange-mode-3136d73d365081ef875acab683d01d9e)
by [Unito](https://www.unito.io)
2026-02-28 14:06:26 -08:00
Alexander Brown
dd1a1f77d6 fix: stabilize nested subgraph promoted widget resolution (#9282)
## Summary

Fix multiple issues with promoted widget resolution in nested subgraphs,
ensuring correct value propagation, slot matching, and rendering for
deeply nested promoted widgets.

## Changes

- **What**: Stabilize nested subgraph promoted widget resolution chain
- Use deep source keys for promoted widget values in Vue rendering mode
- Resolve effective widget options from the source widget instead of the
promoted view
  - Stabilize slot resolution for nested promoted widgets
  - Preserve combo value rendering for promoted subgraph widgets
- Prevent subgraph definition deletion while other nodes still reference
the same type
  - Clean up unused exported resolution types

## Review Focus

- `resolveConcretePromotedWidget.ts` — new recursive resolution logic
for deeply nested promoted widgets
- `useGraphNodeManager.ts` — option extraction now uses
`effectiveWidget` for promoted widgets
- `SubgraphNode.ts` — unpack no longer force-deletes definitions
referenced by other nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-28 13:45:04 -08:00
pythongosssss
0ab3fdc2c9 Add indicator circle when new unseen menu items are available (#9220)
## Summary

Adds a little indicator circle when new workflow menu items are added
that the user has not seen

## Changes

- **What**: Adds a hidden setting to track menu items flagged as new
that have been seen

## Screenshots (if applicable)

<img width="164" height="120" alt="image"
src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b)
by [Unito](https://www.unito.io)
2026-02-28 12:53:26 -08:00
Terry Jia
ec1977131d feat: wrap CURVE widget value with typed format (#9294)
## Summary
Send CURVE values as { __type: 'CURVE', value: [...] } instead of {
__value__: [...] } to avoid ambiguity with link detection and enable
external tools to identify the data type.

change requested by @guill

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9294-feat-wrap-CURVE-widget-value-with-typed-format-3156d73d365081bf8e5de59527e2d3ce)
by [Unito](https://www.unito.io)
2026-02-28 15:00:39 -05:00
Christian Byrne
3f497081ee feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 22:34:27 +08:00
jaeone94
a0e518aa98 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 06:17:30 -08:00
jaeone94
45f112e226 fix: node replacement fails after execution and modal sync (#9269)
## Summary

Fixes two bugs in the node replacement flow: placeholder detection
failing after workflow execution or pack reinstallation, and missing UI
sync in the Errors Tab when replacements are applied from the modal
dialog.

## Changes

- **Placeholder detection**: Node placeholder detection now matches
against `targetTypes` (derived from the replaceable node list built at
workflow load time) instead of relying on `has_errors` flag or
`registered_node_types` lookup. This ensures replacement works reliably
after execution (where `has_errors` gets cleared) and after pack
reinstallation (where the type becomes registered).
- **Modal → Errors Tab sync**: Added
`executionErrorStore.removeMissingNodesByType()` call in
`MissingNodesContent.vue` after replacement, so the Errors Tab reflects
changes immediately without requiring a page reload.

## Review Focus

- `collectAllNodes` predicate change in `useNodeReplacement.ts`: now
uses `targetTypes.has(originalType)` to find nodes by their original
serialized type. This is independent of runtime state like `has_errors`
or `registered_node_types`.
- `executionErrorStore.removeMissingNodesByType` call timing in
`MissingNodesContent.vue` — runs synchronously after
`replaceNodesInPlace` resolves, before auto-close logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9269-fix-node-replacement-fails-after-execution-and-modal-sync-3146d73d365081218398c961639b450f)
by [Unito](https://www.unito.io)
2026-02-28 04:05:58 -08:00
Comfy Org PR Bot
b80eace639 1.41.8 (#9288)
Patch version increment to 1.41.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9288-1-41-8-3156d73d3650817ca737ced3e08d8c86)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 01:07:23 -08:00
Christian Byrne
8da07f2ce2 fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172)
## Summary

Pre-rasterize the SubgraphNode SVG icon to a bitmap canvas to eliminate
Firefox's per-frame SVG style processing.

## Changes

- **What**: Add `getWorkflowBitmap()` that lazily rasterizes the
`data:image/svg+xml` workflow icon to an `HTMLCanvasElement` (16×16) on
first use. `SubgraphNode.drawTitleBox()` draws the cached bitmap instead
of the raw SVG.

## Review Focus

- Firefox re-processes SVG internal stylesheets (`stroke`,
`stroke-linecap`, `stroke-width`) every time `ctx.drawImage(svgImage)`
is called. Chrome caches the rasterization. This happens on every frame
for every visible SubgraphNode.
- Reporter confirmed strong subgraph correlation: "it may be happening
in the default workflow with subgraph" / "didn't seem to happen just
using manually wired up diffusion loader, clip, sampler, etc."
- Falls back to the raw SVG Image if not yet loaded or if
`getContext('2d')` returns null.

## Stack

3 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9172-fix-pre-rasterize-SubgraphNode-SVG-icon-to-bitmap-canvas-3116d73d365081babf17cf0848d37269)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-28 01:06:54 -08:00
AustinMroz
ea5ffcc66e Fix essentials nodes not being marked core (#9287)
In adding an essentials cateogory for nodes, #8987 introduced a
regression where core nodes which are also essential are marked as being
from a `nodes` custom node instead of being marked core. Since the
essentials designation should pre-empt core and custom nodes can choose
to mark themself as essential, the getter for `isCoreNode` is updated to
instead repeat the existing check for if a node is core.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1b8bf80-d072-409a-a0f9-4837e1d11767"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/14ff525b-9833-4e73-888f-791aff6cf531"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9287-Fix-essentials-nodes-not-being-marked-core-3146d73d365081fca2a0f8bdc2baf01a)
by [Unito](https://www.unito.io)
2026-02-27 16:23:08 -08:00
pythongosssss
07dab97aed App builder exit updates (#9218)
## Summary

- remove exit builder button from right panel
- add builder exit button to bottom of canvas
- add builder menu with save & exit in top left

## Screenshots (if applicable)

<img width="1544" height="998" alt="image"
src="https://github.com/user-attachments/assets/f5deadc5-2bf5-4729-b644-2b6a815b9975"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9218-App-builder-exit-updates-3126d73d365081a0bf1adf92e1171060)
by [Unito](https://www.unito.io)
2026-02-27 13:55:05 -08:00
pythongosssss
f83daa6f3b App mode - discard slow preview messages to prevent overwriting output image (#9261)
## Summary

Prevent latent previews received after the job/node has already finished
processing overwriting the actual output display

## Changes

- **What**: 
- updates job preview store to also track which node the preview was for
- updates linear progress tracking to store executed nodes enabling
skipping previews of these

## Review Focus

<!-- 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)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9261-App-mode-discard-slow-preview-messages-to-prevent-overwriting-output-image-3136d73d3650817884c2ce2ff5993b9e)
by [Unito](https://www.unito.io)
2026-02-27 10:58:41 -08:00
pythongosssss
c090d189f0 Render app builder in arrange mode (#9260)
## Summary

Adds app builder in arrange/preview mode with re-orderable widgets,
maintaining size (as much as possible) between the select + preview
steps

## Changes

- **What**: 
- Extract sidebar size constants for sharing between canvas splitter +
app mode
- Add widget list using DraggableList and render inert WidgetItems

## Screenshots (if applicable)

<img width="1391" height="923" alt="image"
src="https://github.com/user-attachments/assets/3e17eafe-db1e-40a3-83b5-15a7d0672909"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9260-Render-app-builder-in-arrange-mode-3136d73d365081ef875acab683d01d9e)
by [Unito](https://www.unito.io)
2026-02-27 02:32:44 -08:00
Alexander Brown
b9c467577c Merge branch 'main' into drjkl/he-come-to-town 2026-02-26 23:49:59 -08:00
Benjamin Lu
7901e14318 fix: make docked job history toggle persistence-safe (#9265)
## Summary
Follow-up to #9215 to keep Docked Job History toggle behavior
deterministic even when settings persistence fails.

## Changes
- Close the actions popover immediately when toggling Docked Job
History.
- Use settingStore.setMany(...) when switching from docked to floating
mode.
- Set sidebarTabStore.activeSidebarTabId = 'job-history' before
persisting when switching from floating to docked mode.
- Wrap persistence calls in try/catch without rollback so
locally-applied UI state remains deterministic.
- Expand QueueOverlayHeader tests to cover setMany, popover close
behavior, and persistence-failure paths.

## Testing
- pnpm test:unit -- src/components/queue/QueueOverlayHeader.test.ts
- pnpm typecheck
- pnpm exec eslint src/components/queue/JobHistoryActionsMenu.vue
src/components/queue/QueueOverlayHeader.test.ts
- pnpm lint (fails in this branch due pre-existing stylelint errors in
generated apps/desktop-ui/dist/**/*.css files, unrelated to this change)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9265-fix-make-docked-job-history-toggle-persistence-safe-3146d73d3650818f86c4dfdd57669abd)
by [Unito](https://www.unito.io)
2026-02-26 19:49:08 -08:00
Benjamin Lu
c8b1cd9dfb fix: remove beta labeling from comfy cloud badges (#9184)
Remove the BETA label from Comfy Cloud badges while keeping the `Comfy
Cloud` text.

This updates both paths that render Comfy Cloud badge content:
- `src/extensions/core/cloudBadges.ts` (topbar extension badge path)
- `src/components/topbar/CloudBadge.vue` (reusable cloud badge used in
subscription UI)

<img width="479" height="106" alt="image"
src="https://github.com/user-attachments/assets/a73e0607-e747-4335-b09e-cf45b5016ff5"
/>

Reasoning:

https://comfy-organization.slack.com/archives/C08V9NDB3B4/p1771981530055409?thread_ts=1771978875.127499&cid=C08V9NDB3B4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9184-fix-remove-beta-labeling-from-comfy-cloud-badges-3126d73d365081e993aac651993010e7)
by [Unito](https://www.unito.io)
2026-02-26 19:48:55 -08:00
Comfy Org PR Bot
5e99faadfe 1.41.7 (#9264)
Patch version increment to 1.41.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9264-1-41-7-3146d73d36508108a0d1c3e418216cd0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 19:31:49 -08:00
Benjamin Lu
f495f07469 fix: move active jobs button into actionbar (#9211)
## Summary

Move the top menu `N active` queue button into `ComfyActionbar` so it
stays attached to the actionbar when docked, dragged, or floating.

## Changes

- Moved the `queue-overlay-toggle` button UI from `TopMenuSection.vue`
into `ComfyActionbar.vue`
- Moved queue button behavior and context menu handling (`toggle`,
right-click clear queue, active count badge/label) into
`ComfyActionbar.vue`
- Removed now-unused queue button state/handlers/imports from
`TopMenuSection.vue`

<img width="513" height="101" alt="image"
src="https://github.com/user-attachments/assets/6ed85237-4293-47a6-a0fb-258d0182889d"
/>

<img width="778" height="145" alt="image"
src="https://github.com/user-attachments/assets/6e5ef423-c5fc-471f-b9d0-a4bd8dc5d072"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9211-fix-move-active-jobs-button-into-actionbar-3126d73d365081ceb553c172db479e3b)
by [Unito](https://www.unito.io)
2026-02-26 19:00:10 -08:00
Christian Byrne
1054ba8949 fix: batch updateClipPath via requestAnimationFrame (#9173)
## Summary

Batch `getBoundingClientRect()` calls in `updateClipPath` via
`requestAnimationFrame` to avoid forced synchronous layout.

## Changes

- **What**: Wrap the layout-reading portion of `updateClipPath` in
`requestAnimationFrame()` with cancellation. Multiple rapid calls within
the same frame are coalesced into a single layout read. Eliminates
~1,053 forced synchronous layouts per profiling session.

## Review Focus

- `getBoundingClientRect()` forces synchronous layout. When interleaved
with style mutations (from PrimeVue `useStyle`, cursor writes, Vue VDOM
patching), this creates layout thrashing — especially in Firefox where
Stylo aggressively invalidates the entire style cache.
- The RAF wrapper coalesces all calls within a frame into one, reading
layout only once per frame. The `cancelAnimationFrame` ensures only the
latest parameters are used.
- `willChange: 'clip-path'` is included to hint the browser to optimize
clip-path animations.

## Stack

4 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9173-fix-batch-updateClipPath-via-requestAnimationFrame-3116d73d3650810392f7fba7ea5ceb6f)
by [Unito](https://www.unito.io)
2026-02-26 18:53:14 -08:00
Christian Byrne
0698ec23c0 feat: wire essentials_category for Essentials tab display (#9091)
## Summary

Wire `essentials_category` through from backend to the Essentials tab
UI. Creates a single source of truth for node categorization and
ordering.

### Changes

**New file — `src/constants/essentialsNodes.ts`:**
- Single source of truth: `ESSENTIALS_NODES` (ordered nodes per
category), `ESSENTIALS_CATEGORIES` (folder display order),
`ESSENTIALS_CATEGORY_MAP` (flat lookup), `TOOLKIT_NOVEL_NODE_NAMES`
(telemetry), `TOOLKIT_BLUEPRINT_MODULES`

**Refactored files:**
- `src/types/nodeSource.ts`: Removed inline `ESSENTIALS_CATEGORY_MOCK`,
imports `ESSENTIALS_CATEGORY_MAP` from centralized constants
- `src/services/nodeOrganizationService.ts`: Removed inline
`NODE_ORDER_BY_FOLDER`, imports `ESSENTIALS_NODES` and
`ESSENTIALS_CATEGORIES`
- `src/constants/toolkitNodes.ts`: Re-exports from `essentialsNodes.ts`
instead of maintaining a separate list

**Subgraph passthrough:**
- `src/stores/subgraphStore.ts`: Passes `essentials_category` from
`GlobalSubgraphData` and extracts it from `definitions.subgraphs[0]` as
fallback
- `src/platform/workflow/validation/schemas/workflowSchema.ts`: Added
`essentials_category` to `SubgraphDefinitionBase` and
`zSubgraphDefinition`

**Tests:**
- `src/constants/essentialsNodes.test.ts`: 6 tests validating no
duplicates, complete coverage, basics exclusion
- `src/stores/subgraphStore.test.ts`: 2 tests for essentials_category
passthrough

All 43 relevant tests pass. Typecheck, lint, format clean.

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

Fixes COM-15221

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9091-feat-wire-essentials_category-for-Essentials-tab-display-30f6d73d3650814ab3d4c06b451c273b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 18:40:15 -08:00
Johnpaul Chiwetelu
54b710b239 [refactor] Rename queueIndex variables to reflect job.priority usage (#9258)
## Summary
Rename `lastHistoryQueueIndex` → `lastJobHistoryPriority` and
`currentQueueIndex` → `currentJobPriority` to reflect that these
variables now read `job.priority` directly.

## Changes
- **queueStore.ts**: `lastHistoryQueueIndex` → `lastJobHistoryPriority`
- **JobDetailsPopover.vue**: `currentQueueIndex` → `currentJobPriority`
- **queueStore.test.ts**: Updated references and test descriptions

Fixes #9246

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9258-refactor-Rename-queueIndex-variables-to-reflect-job-priority-usage-3136d73d36508126989dd464f7dad9a1)
by [Unito](https://www.unito.io)
2026-02-26 18:31:27 -08:00
jaeone94
1c3984a178 feat: add node replacement UI to Errors Tab (#9253)
## Summary

Adds a node replacement UI to the Errors Tab so users can swap missing
nodes with compatible alternatives directly from the error panel,
without opening a separate dialog.

## Changes

- **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render
swap groups in the Errors Tab; each group shows the missing node type,
its instances (with locate buttons), and a Replace button. Added
`useMissingNodeScan` composable to scan the graph for missing nodes and
populate `executionErrorStore`. Added `removeMissingNodesByType()` to
`executionErrorStore` so replaced nodes are pruned from the error list
reactively.

## Bug Fixes Found During Implementation

### Bug 1: Replaced nodes render as empty shells until page refresh

`replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue
rendering pipeline entirely. Because the replacement node reuses the
same ID, `vueNodeData` retains the stale entry from the old placeholder
(`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only
repaints the LiteGraph canvas and has no effect on Vue.

**Fix**: After `replaceWithMapping()`, manually call
`nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in
`useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and
updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded`
to skip `layoutStore.createNode()` when a layout for the same ID already
exists, preventing a duplicate `spatialIndex.insert()`.

### Bug 2: Missing node error list overwritten by incomplete server
response

Two compounding issues: (A) the server's `missing_node_type` error only
reports the *first* missing node — the old handler parsed this and
called `surfaceMissingNodes([singleNode])`, overwriting the full list
collected at load time. (B) `queuePrompt()` calls `clearAllErrors()`
before the API request; if the subsequent rescan used the stale
`has_errors` flag and found nothing, the missing nodes were permanently
lost.

**Fix**: Created `useMissingNodeScan.ts` which scans
`LiteGraph.registered_node_types` directly (not `has_errors`). The
`missing_node_type` catch block in `app.ts` now calls
`rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the
server's partial response.

## Review Focus

- `handleReplaceNode` removes the group from the store only when
`replaceNodesInPlace` returns at least one replaced node — should we
always clear, or only on full success?
- `useMissingNodeScan` re-scans on every execution-error change; confirm
no performance concerns for large graphs with many subgraphs.


## Screenshots 


https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50



https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449)
by [Unito](https://www.unito.io)
2026-02-26 17:37:48 -08:00
Benjamin Lu
367d96715b fix: open target panel when toggling Docked Job History (#9215)
## Summary
Make the Docked Job History toggle deterministic so it opens the
expected UI target in both directions.

## Changes
- Update `JobHistoryActionsMenu` toggle behavior:
- When currently docked (`Comfy.Queue.QPOV2=true`), disable docked mode
and explicitly open floating QPO (`Comfy.Queue.History.Expanded=true`)
- When currently floating (`Comfy.Queue.QPOV2=false`), enable docked
mode and open the `job-history` sidebar tab
- Add/adjust unit tests in `QueueOverlayHeader.test.ts` to verify both
toggle directions and target panel behavior

## Testing
- `pnpm exec eslint src/components/queue/JobHistoryActionsMenu.vue
src/components/queue/QueueOverlayHeader.test.ts`
- `pnpm typecheck`
- `pnpm test:unit -- src/components/queue/QueueOverlayHeader.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9215-fix-open-target-panel-when-toggling-Docked-Job-History-3126d73d3650810eb409ff38e3a521f3)
by [Unito](https://www.unito.io)
2026-02-26 16:21:31 -08:00
Benjamin Lu
84fdf55902 fix: set queue job filter tabs to 32px (#9217)
## Summary
- Increase queue job filter tab button height from `sm` to `md` (`32px`
equivalent).
- Apply consistently to both floating Queue Progress Overlay and docked
Job History sidebar, since both use `JobFilterTabs`.

## Design
-
https://www.figma.com/board/R9eN9DHmDgX3qEJXsRKiRr/QA-Feedback--Alex-?node-id=273-111&t=OUCBdoZhwrOMsxXE-4

## Testing
- `pnpm typecheck`
- `pnpm lint` (passes with existing repo warnings only)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9217-fix-set-queue-job-filter-tabs-to-32px-3126d73d36508106a9c8e3786ab77aa5)
by [Unito](https://www.unito.io)
2026-02-26 16:09:26 -08:00
Alexander Brown
9028ee9cfe fix: keep link store topology type internal
- remove unused exported LinkStoreTopology type to satisfy knip pre-push checks

Amp-Thread-ID: https://ampcode.com/threads/T-019c9c03-dca7-757a-af61-5f41ae8d2dec
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 15:07:39 -08:00
Alexander Brown
8026585721 fix: finalize link topology store migration
- switch LGraph link topology lookups and rehydration to linkStoreKey

- update litegraph tests to use graph-scoped linkStoreKey instead of graph.id

- add legacy compatibility warnings for slot dedupe, fixLinkInputSlots narrowing, and subgraph output disconnect callback parity

- initialize testing Pinia globally in vitest setup and document non-reactive link store intent

Amp-Thread-ID: https://ampcode.com/threads/T-019c9c03-dca7-757a-af61-5f41ae8d2dec
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 15:05:30 -08:00
Alexander Brown
9f1376d79e refactor: extract addAfterConfigureHandler to graphConfigureUtil
Move private method from ComfyApp to a standalone exported utility function.

Eliminates unsafe 'as any' cast in tests by making the function directly importable.

Amp-Thread-ID: https://ampcode.com/threads/T-019c9bda-0293-7528-9b0a-f72444199cbe
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:38:21 -08:00
Alexander Brown
2686becdb9 fix: correct disconnect dispatch args, import typo, and test timeout
- LGraphNode: use link_info.origin_slot instead of array index for
  sourceSlotIndex in dispatchDisconnectNodePair
- SubgraphInputNode: pass node input slot instead of subgraphInput
  for INPUT disconnect dispatch
- SubgraphIO.test: fix double slash in import path
- NightlySurveyPopover.test: remove unnecessary 15000ms timeout
  on fake-timer test

Amp-Thread-ID: https://ampcode.com/threads/T-019c9bcb-2c95-712d-8978-ab8e9e688e4d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:25:15 -08:00
Alexander Brown
3331ab90f2 fix: harden subgraph disconnect cleanup for stale links
Amp-Thread-ID: https://ampcode.com/threads/T-019c9bb5-a77c-72ef-9b51-5394c20ee671
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:08:48 -08:00
Alexander Brown
7d737a534d Fix: expectation on renaming. The label and name now align, deduping happens on manual rename 2026-02-26 12:28:36 -08:00
pythongosssss
9fb93a5b0a App mode - more updates & fixes (#9137)
## Summary

- fix sizing of sidebars in app mode
- update feedback button to match design
- update job queue notification
- clickable queue spinner item to allow clear queue
- refactor mode out of store to specific workflow instance
- support different saved vs active mode
- other styling/layout tweaks

## Changes

- **What**: Changes the store to a composable and moves the mode state
to the workflow.
- This enables switching between tabs and maintaining the mode they were
in

## Screenshots (if applicable)
<img width="1866" height="1455" alt="image"
src="https://github.com/user-attachments/assets/f9a8cd36-181f-4948-b48c-dd27bd9127cf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9137-App-mode-more-updates-fixes-3106d73d365081a18ccff6ffe24fdec7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 09:55:10 -08:00
Benjamin Lu
ac12a3d9b9 fix: preserve refill date slashes in subscription credits label (#9251)
### Motivation
- Subscription credit labels were rendering the refill date with
HTML-escaped separators (`&#x2F;`) because `vue-i18n` parameter escaping
was applied to the date interpolation.
- The goal is to render date-only parameters like `MM/DD/YY` with
literal slashes so the UI shows a human-readable date string.

### Description
- Disabled `vue-i18n` parameter escaping for the
`subscription.creditsRemainingThisMonth` and
`subscription.creditsRemainingThisYear` lookups in both subscription
panels by passing `{ escapeParameter: false }` to `t()` in
`SubscriptionPanelContentLegacy.vue` and
`SubscriptionPanelContentWorkspace.vue`.
- Adjusted the unit test i18n setup in `SubscriptionPanel.test.ts` to
include `escapeParameter: true` in the test `i18n` instance and updated
the test messages to use `Included (Refills {date})`.
- Added a regression unit test in `SubscriptionPanel.test.ts` asserting
the rendered label contains `Included (Refills 12/31/24)` and does not
contain the escaped entity `&#x2F;`.

### Testing
- Ran formatting with `pnpm format` which completed successfully.
- Ran lint via `pnpm lint` which passed with pre-existing warnings only
(no new errors).
- Ran type checking with `pnpm typecheck` (via `vue-tsc --noEmit`) which
completed successfully.
- Ran the modified unit tests with `pnpm vitest run
src/platform/cloud/subscription/components/SubscriptionPanel.test.ts`
and the test file passed (10 passed, 5 skipped).
- Attempted a Playwright-based visual capture of the running app but
Chromium crashed in this environment (SIGSEGV) before navigation, so no
screenshot was produced.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69a0175f58788330b2256329a500e14b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9251-fix-preserve-refill-date-slashes-in-subscription-credits-label-3136d73d36508182b770f5719a52d189)
by [Unito](https://www.unito.io)
2026-02-26 09:37:03 -08:00
Johnpaul Chiwetelu
45ca1beea2 fix: address small CodeRabbit issues (#9229)
## Summary

Address several small CodeRabbit-filed issues: clipboard simplification,
queue getter cleanup, pointer handling, and test parameterization.

## Changes

- **What**:
- Simplify `useCopyToClipboard` by using VueUse's built-in `legacy` mode
instead of a manual `document.execCommand` fallback
- Remove `queueIndex` getter alias from `TaskItemImpl`, replace all
usages with `job.priority`
- Add `pointercancel` event handling and try-catch around
`releasePointerCapture` in `useNodeResize` to prevent stuck resize state
- Parameterize repetitive `getNodeProvider` tests in
`modelToNodeStore.test.ts` using `it.each()`

- Fixes #9024
- Fixes #7955
- Fixes #7323
- Fixes #8703

## Review Focus

- `useCopyToClipboard`: VueUse's `legacy: true` enables the
`execCommand` fallback internally — verify browser compat is acceptable
- `useNodeResize`: cleanup logic extracted into shared function used by
both `pointerup` and `pointercancel`
2026-02-26 02:32:53 -08:00
Alexander Brown
0331c1c726 test: stabilize flaky unit expectations
Amp-Thread-ID: https://ampcode.com/threads/T-019c98dc-6f6a-7125-b046-418a6ccdffb1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 01:42:38 -08:00
Christian Byrne
aef299caf8 fix: add GLSLShader to canvas image preview node types (#9198)
## Summary

Add `GLSLShader` to `CANVAS_IMAGE_PREVIEW_NODE_TYPES` so GLSL shader
previews are promoted through subgraph nodes.

## Changes

- Add `'GLSLShader'` to the `CANVAS_IMAGE_PREVIEW_NODE_TYPES` set in
`src/composables/node/useNodeCanvasImagePreview.ts`

## Context

GLSLShader node previews were not showing on parent subgraph nodes
because `CANVAS_IMAGE_PREVIEW_NODE_TYPES` only included `PreviewImage`
and `SaveImage`. The `$$canvas-image-preview` pseudo-widget was never
created for GLSLShader nodes, so the promotion system had nothing to
promote. This degraded the UX of all 12 shipped GLSL blueprint subgraphs
— users couldn't see shader output previews without expanding the
subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9198-fix-add-GLSLShader-to-canvas-image-preview-node-types-3126d73d3650817dbe9beab4bdeaa414)
by [Unito](https://www.unito.io)
2026-02-26 01:15:24 -08:00
Johnpaul Chiwetelu
188fafa89a fix: address trivial CodeRabbit issues (#9196)
## Summary

Address several trivial CodeRabbit-filed issues: type guard extraction,
ESLint globals, curve editor optimizations, and type relocation.

## Changes

- **What**: Extract `isSingleImage()` type guard in WidgetImageCompare;
add `__DISTRIBUTION__`/`__IS_NIGHTLY__` to ESLint globals and remove
stale disable comments; remove unnecessary `toFixed(4)` from curve path
generation; optimize `histogramToPath` with array join; move
`CurvePoint` type to curve domain

- Fixes #9175
- Fixes #8281
- Fixes #9116
- Fixes #9145
- Fixes #9147

## Review Focus

All changes are mechanical/trivial. Curve path output changes from
fixed-precision to raw floats — SVG handles both fine.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9196-fix-address-trivial-CodeRabbit-issues-3126d73d365081f19a5ce20305403098)
by [Unito](https://www.unito.io)
2026-02-26 00:43:14 -08:00
Christian Byrne
3984408d05 docs: add comment explaining widget value store dom widgets getter nuance (#9202)
Adds comment explaining nuance with the differing registration semantics
between DOM widget vs base widet.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9202-fix-widget-value-store-dom-widgets-getter-3126d73d365081368b94f048efb101fa)
by [Unito](https://www.unito.io)
2026-02-25 23:44:33 -08:00
Alexander Brown
025a803ac0 feat: narrow legacy slot repair to mismatch cases
Amp-Thread-ID: https://ampcode.com/threads/T-019c98dc-6f6a-7125-b046-418a6ccdffb1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:39:57 -08:00
Alexander Brown
34786a16c0 feat: unify lifecycle event dispatcher callbacks
Amp-Thread-ID: https://ampcode.com/threads/T-019c98d1-7608-73e5-9da5-3ae48178b28b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:30:37 -08:00
Alexander Brown
6580500206 feat: isolate persistence and node remap adapters
Amp-Thread-ID: https://ampcode.com/threads/T-019c98c7-4fff-75cc-8950-0db38ec987fd
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:18:05 -08:00
Alexander Brown
e17a2c669b feat: extract shared subgraph boundary remap adapter
Amp-Thread-ID: https://ampcode.com/threads/T-019c98bb-ebfb-77fc-b105-1d04e023db7a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:07:34 -08:00
Alexander Brown
1c4c000745 feat: normalize subgraph slot identity by canonical name
Amp-Thread-ID: https://ampcode.com/threads/T-019c98b1-8cb2-704f-b7ac-7accee5228d0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:54:50 -08:00
Alexander Brown
38ed3124a0 feat: centralize subgraph io mutation paths
Amp-Thread-ID: https://ampcode.com/threads/T-019c98a5-85ea-75ec-9594-aea357107cf6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:41:39 -08:00
Alexander Brown
ae5bfe9428 feat: centralize normal connect mutation path
Amp-Thread-ID: https://ampcode.com/threads/T-019c989e-444b-73fd-b60d-9d9e447d1212
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:28:57 -08:00
Alexander Brown
6c94b9faf8 feat: centralize disconnect/remove link mutation path
Amp-Thread-ID: https://ampcode.com/threads/T-019c9896-0c38-760a-a51c-3750734becc1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:23:02 -08:00
Christian Byrne
6034be9a6f fix: add GLSLShader to toolkit node telemetry tracking (#9197)
## Summary

Add `GLSLShader` to `TOOLKIT_NODE_NAMES` so Mixpanel telemetry tracks
GLSL shader node usage alongside other toolkit nodes.

## Changes

- Add `'GLSLShader'` to the `TOOLKIT_NODE_NAMES` set in
`src/constants/toolkitNodes.ts`

## Context

The Toolkit Nodes PRD defines success metrics that require tracking "%
of workflows using one of these nodes" and "how often each node is
used." GLSLShader was missing from the tracking list, so no
GLSL-specific telemetry was being collected despite 12 GLSL blueprints
shipping in prod (BlueprintsVersion 0.9.1).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9197-fix-add-GLSLShader-to-toolkit-node-telemetry-tracking-3126d73d3650814dad05fa78382d5064)
by [Unito](https://www.unito.io)
2026-02-25 22:19:50 -08:00
Alexander Brown
bad3fa5f2f feat: route link topology reads through store projection
Amp-Thread-ID: https://ampcode.com/threads/T-019c9886-c7b7-73f0-b85a-8a046e556ca8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:10:23 -08:00
Alexander Brown
1efc931e2e feat: add link store lifecycle rehydration contract
- add passive LinkStore for links, floating links, and reroutes

- rehydrate LinkStore on graph clear and configure lifecycle paths

- add unit and browser tests for ChangeTracker/link topology undo-redo parity

Amp-Thread-ID: https://ampcode.com/threads/T-019c9840-2f49-701c-bdf2-ae7f2f50acef
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 21:57:12 -08:00
Christian Byrne
6a08e4ddde Revert "fix: sync DOM widget values to widgetValueStore on registration" (#9205)
Reverts Comfy-Org/ComfyUI_frontend#9166

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9205-Revert-fix-sync-DOM-widget-values-to-widgetValueStore-on-registration-3126d73d365081df8944d3c6508d2372)
by [Unito](https://www.unito.io)
2026-02-25 21:36:52 -08:00
Hunter
9ff985a792 fix: sync DOM widget default values to widgetValueStore on registration (#9164)
## Description

DOM widgets (textarea/customtext) override the `value` getter via
`Object.defineProperty` to use `getValue()/setValue()` with a fallback
to `inputEl.value`. But `BaseWidget.setNodeId()` registered
`_state.value` (undefined from constructor) instead of `this.value` (the
actual getter).

This caused Vue nodes (Nodes 2.0) to read `undefined` from the store and
display empty textareas, while execution correctly fell back to
`inputEl.value`.

**Fix:** Use `this.value` in `setNodeId()` so the store is initialized
with the actual widget value.

**Impact:** Fixes Nano Banana / Nano Banana Pro `system_prompt` showing
empty in Nodes 2.0 while still sending the correct value during
execution.

## Thread

https://ampcode.com/threads/T-019c8e99-49ce-77f5-bf2a-a32320fac477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9164-fix-sync-DOM-widget-default-values-to-widgetValueStore-on-registration-3116d73d36508169a2fbd8308d9eec91)
by [Unito](https://www.unito.io)
2026-02-25 21:35:59 -08:00
Terry Jia
5cfd1aa77e feat: add Painter Node (#8521)
## Summary
Add PainterNode widget for freehand mask drawing directly on the canvas,
with brush/eraser tools, opacity, hardness, and background color
controls.

need BE changes https://github.com/Comfy-Org/ComfyUI/pull/12294

## Screenshots (if applicable)


https://github.com/user-attachments/assets/7222063a-0e40-40bb-b72e-b42c8984beb9



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8521-feat-add-Painter-Node-2fa6d73d36508124ab2ede449a0cc67a)
by [Unito](https://www.unito.io)
2026-02-25 21:08:49 -08:00
Christian Byrne
2cb4c5eff3 fix: textarea stays disabled after link disconnect on promoted widgets (#9199)
## Summary

Fix textarea widgets staying disabled after disconnecting a link on
promoted widgets in subgraphs.

## Changes

- **What**: `refreshNodeSlots` used `SafeWidgetData.name` for slot
metadata lookups, but for promoted widgets this is `sourceWidgetName`
(the interior widget name), which doesn't match the subgraph node's
input slot widget name. Added `slotName` field to `SafeWidgetData` to
track the original LiteGraph widget name, and updated `refreshNodeSlots`
to use `slotName ?? name` for correct matching.

## Review Focus

The key change is the `slotName` field on `SafeWidgetData` — it's only
populated when `name !== widget.name` (i.e., for promoted widgets). The
`refreshNodeSlots` function now uses `widget.slotName ?? widget.name` to
look up slot metadata, ensuring promoted widgets correctly update their
`linked` state on disconnect.

Fixes #8818

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9199-fix-textarea-stays-disabled-after-link-disconnect-on-promoted-widgets-3126d73d3650813db499c227e6587aca)
by [Unito](https://www.unito.io)
2026-02-25 20:50:11 -08:00
Benjamin Lu
b8cca4167b fix: show inline progress in QPOV2 despite stale overlay flag (#9214)
## Summary

Fix inline queue progress being hidden in QPOV2 mode when a stale
`Comfy.Queue.History.Expanded` setting remains true from legacy queue
overlay usage.

## Changes

- Update actionbar inline progress hide condition to respect
queue-overlay expansion only when QPOV2 is disabled
- Update top menu inline progress summary hide condition with the same
gate
- Keep legacy behavior unchanged for non-QPOV2 queue overlay mode

## Testing

- `pnpm exec eslint src/components/actionbar/ComfyActionbar.vue
src/components/TopMenuSection.vue` 
- `pnpm typecheck` 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9214-fix-show-inline-progress-in-QPOV2-despite-stale-overlay-flag-3126d73d36508170ac27fbb26826dca9)
by [Unito](https://www.unito.io)
2026-02-25 20:42:17 -08:00
Benjamin Lu
d99d807c45 fix: open job history from top menu active jobs button (#9210)
## Summary

Make the top menu `N active` queue button open the Job History sidebar
tab when QPO V2 is enabled, so behavior matches the button label and
accessibility text.

## Changes

- Update `TopMenuSection.vue` so QPO V2 mode toggles `job-history`
instead of `assets`
- Update `aria-pressed` logic to track `job-history`
- Update `TopMenuSection` unit tests to assert `job-history`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9210-fix-open-job-history-from-top-menu-active-jobs-button-3126d73d365081758987fa3806b4b0e7)
by [Unito](https://www.unito.io)
2026-02-25 20:40:11 -08:00
jaeone94
80fe51bb8c feat: show missing node packs in Errors Tab with install support (#9213)
## Summary

Surfaces missing node pack information in the Errors Tab, grouped by
registry pack, with one-click install support via ComfyUI Manager.

## Changes

- **What**: Errors Tab now groups missing nodes by their registry pack
and shows a `MissingPackGroupRow` with pack name, node/pack counts, and
an Install button that triggers Manager installation. A
`MissingNodeCard` shows individual unresolvable nodes that have no
associated pack. `useErrorGroups` was extended to resolve missing node
types to their registry packs using the `/api/workflow/missing_nodes`
endpoint. `executionErrorStore` was refactored to track missing node
types separately from execution errors and expose them reactively.
- **Breaking**: None

## Review Focus

- `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches
pack metadata and maps node types to pack IDs; edge cases around partial
resolution (some nodes have a pack, some don't) produce both
`MissingPackGroupRow` and `MissingNodeCard` entries
- `executionErrorStore.ts` — the store now separates `missingNodeTypes`
state from `errors`; the deferred-warnings path in `app.ts` now calls
`setMissingNodeTypes` so the Errors Tab is populated even when a
workflow loads without executing

## Screenshots (if applicable)


https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8)
by [Unito](https://www.unito.io)
2026-02-25 20:25:47 -08:00
Dante
c24c4ab607 feat: show loading spinner and uploading filename during image upload (#9189)
## Summary
- Show a canvas-based loading spinner on image upload nodes (LoadImage)
during file upload via drag-drop, paste, or file picker
- Display the uploading file's name immediately in the filename dropdown
instead of showing the previous file's name
- Show the uploading audio file's name immediately in the audio widget
during upload

## Changes
- **`useNodeImageUpload.ts`**: Add `isUploading` flag and
`onUploadStart` callback to the upload lifecycle; clear `node.imgs`
during upload to prevent stale previews
- **`useImagePreviewWidget.ts`**: Add `renderUploadSpinner` that draws
an animated arc spinner on the canvas when `node.isUploading` is true;
guard against empty `imgs` array
- **`useImageUploadWidget.ts`**: Set `fileComboWidget.value` to the new
filename on upload start; clear `node.imgs` on combo widget change
- **`uploadAudio.ts`**: Set `audioWidget.value` to the new filename on
upload start
- **`litegraph-augmentation.d.ts`**: Add `isUploading` property to
`LGraphNode`



https://github.com/user-attachments/assets/818ce529-cb83-428a-8c98-dd900a128343



## Test plan
- [x] Upload an image via file picker on LoadImage node — spinner shows
during upload, filename updates immediately
- [x] Drag-and-drop an image onto LoadImage node — same behavior
- [x] Paste an image onto LoadImage node — same behavior
- [x] Change the dropdown selection on LoadImage — old preview clears,
new image loads
- [x] Upload an audio file — filename updates immediately in the widget

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9189-feat-show-loading-spinner-and-uploading-filename-during-image-upload-3126d73d365081e4af27cd7252f34298)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:22:42 -08:00
Christian Byrne
8e215b3174 feat: add performance testing infrastructure with CDP metrics (#9170)
## Summary

Add a permanent, non-failing performance regression detection system
using Chrome DevTools Protocol metrics, with automatic PR commenting.

## Changes

- **What**: Performance testing infrastructure — `PerformanceHelper`
fixture class using CDP `Performance.getMetrics` to collect
`RecalcStyleCount`, `LayoutCount`, `LayoutDuration`, `TaskDuration`,
`JSHeapUsedSize`. Adds `@perf` Playwright project (Chromium-only,
single-threaded, 60s timeout), 4 baseline perf tests, CI workflow with
sticky PR comment reporting, and `perf-report.js` script for generating
markdown comparison tables.

## Review Focus

- `PerformanceHelper` uses `page.context().newCDPSession(page)` — CDP is
Chromium-only, so perf metrics are not collected on Firefox. This is
intentional since CDP gives us browser-level style recalc/layout counts
that `performance.mark/measure` cannot capture.
- The CI workflow uses `continue-on-error: true` so perf tests never
block merging.
- Baseline comparison uses `dawidd6/action-download-artifact` to
download metrics from the target branch, following the same pattern as
`pr-size-report.yaml`.

## Stack

This is the foundation PR for the Firefox performance fix stack:
1. **→ This PR: perf testing infrastructure**
2. `perf/fix-cursor-cache` — cursor style caching (depends on this)
3. `perf/fix-subgraph-svg` — SVG pre-rasterization (depends on this)
4. `perf/fix-clippath-raf` — RAF batching for clip-path (depends on
this)

PRs 2-4 are independent of each other.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9170-feat-add-performance-testing-infrastructure-with-CDP-metrics-3116d73d3650817cb43def6f8e9917f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-25 20:09:57 -08:00
Benjamin Lu
c957841862 fix: open previewable assets from list preview click/double-click (#9077)
## Summary
- emit `preview-click` from `AssetsListItem` when clicking the preview
tile
- wire assets sidebar rows and queue job-history rows so preview-tile
click and row double-click open the viewer/gallery
- gate job-history preview opening by `taskRef.previewOutput` (not
`iconImageUrl`) and use preview output URL/type so video previews are
supported
- add/extend tests for preview click and double-click behavior in assets
list and job history

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9077-fix-open-previewable-assets-from-list-preview-click-double-click-30f6d73d3650810a873cfa2dc085bf97)
by [Unito](https://www.unito.io)
2026-02-25 18:03:07 -08:00
Comfy Org PR Bot
d23c8026d0 1.41.6 (#9222)
Patch version increment to 1.41.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9222-1-41-6-3136d73d36508199bccbe6e08335bb19)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-25 17:44:51 -08:00
AustinMroz
a309281ac5 Prevent serialization of progress text to prompt (#9221)
#8625 fixed a bug where `ProgressTextWidget`s would be serialized to
workflow data and, under rare circumstances, clobber over other widget
values on restore.

I was mistaken that the `serialize: false` being sent to options does
serve a purpose: preventing the widget value from being serialized to
the (api) prompt which is sent to the backend. This PR reverts the
removal so now both forms of disabling serialization apply.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9221-Prevent-serialization-of-progress-text-to-prompt-3126d73d365081c5b9ecc560f0a248d5)
by [Unito](https://www.unito.io)
2026-02-25 17:25:12 -08:00
Dante
e9bf113686 feat(settings): improve search to include nav items and show all results (#9195)
## Summary
- Settings search now matches sidebar navigation items (Keybinding,
About, Extension, etc.) and navigates to the corresponding panel
- Search results show all matching settings across all categories
instead of filtering to only the first matching category
- Search result group headers display parent category prefix (e.g.
"LiteGraph › Node") for clarity

## Test plan
- [x] Search "Keybinding" → sidebar highlights and navigates to
Keybinding panel
- [x] Search "badge" → shows all 4 badge settings (3 LiteGraph + 1
Comfy)
- [x] Search "canvas" → shows results from all categories
- [x] Clear search → returns to default category
- [x] Unit tests pass (`pnpm test:unit`)
<img width="1425" height="682" alt="스크린샷 2026-02-25 오후 3 01 05"
src="https://github.com/user-attachments/assets/956c4635-b140-4dff-8145-db312d295160"
/>



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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9195-feat-settings-improve-search-to-include-nav-items-and-show-all-results-3126d73d3650814dbf3ce1d59ad962cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-25 17:14:37 -08:00
AustinMroz
1ab48b42a7 Add App I/O selection system (#8965)
Adds a system for selecting the inputs and outputs which should be
displayed when inside linear mode. Functions only in litegraph
currently. Vue support will require a separate, larger PR.
Inputs and outputs can be re-ordered by dragging and dropping on the
side panel.

![builder_00001](https://github.com/user-attachments/assets/6345adbd-519e-455d-b71e-0020aa03c6b7)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8965-Add-App-I-O-selection-system-30b6d73d365081569b36c1682a1fdbc5)
by [Unito](https://www.unito.io)
2026-02-25 08:53:00 -08:00
437 changed files with 27082 additions and 3940 deletions

View File

@@ -5,3 +5,10 @@ reviews:
high_level_summary: false
auto_review:
drafts: true
ignore_title_keywords:
- '[release]'
- '[backport'
ignore_usernames:
- comfy-pr-bot
- github-actions
- github-actions[bot]

136
.github/workflows/ci-perf-report.yaml vendored Normal file
View File

@@ -0,0 +1,136 @@
name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()
uses: actions/upload-artifact@v6
with:
name: perf-metrics
path: test-results/perf-metrics.json
retention-days: 30
if-no-files-found: warn
report:
needs: perf-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Download PR perf metrics
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: perf-metrics
path: test-results/
- name: Download baseline perf metrics
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ github.event.pull_request.base.ref }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download historical perf baselines
continue-on-error: true
run: |
RUNS=$(gh api \
"/repos/${{ github.repository }}/actions/workflows/ci-perf-report.yaml/runs?branch=${{ github.event.pull_request.base.ref }}&event=push&status=success&per_page=5" \
--jq '.workflow_runs[].id' || true)
if [ -z "$RUNS" ]; then
echo "No historical runs available"
exit 0
fi
mkdir -p temp/perf-history
INDEX=0
for RUN_ID in $RUNS; do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
gh run download "$RUN_ID" -n perf-metrics -D "$DIR/" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Downloaded $(ls temp/perf-history/*/perf-metrics.json 2>/dev/null | wc -l) historical baselines"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Read perf report
id: perf-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./perf-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
body: |
${{ steps.perf-report.outputs.content }}
<!-- COMFYUI_FRONTEND_PERF -->
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'

View File

@@ -0,0 +1,45 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -35,7 +35,7 @@
}
],
"no-control-regex": "off",
"no-eval": "off",
"no-eval": "error",
"no-redeclare": "error",
"no-restricted-imports": [
"error",

View File

@@ -0,0 +1,760 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

View File

@@ -24,6 +24,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
@@ -185,6 +186,7 @@ export class ComfyPage {
public readonly dragDrop: DragDropHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -229,6 +231,7 @@ export class ComfyPage {
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
}
get visibleToasts() {
@@ -436,7 +439,13 @@ export const comfyPageFixture = base.extend<{
}
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -0,0 +1,96 @@
import type { CDPSession, Page } from '@playwright/test'
interface PerfSnapshot {
RecalcStyleCount: number
RecalcStyleDuration: number
LayoutCount: number
LayoutDuration: number
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
}
export interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
export class PerformanceHelper {
private cdp: CDPSession | null = null
private snapshot: PerfSnapshot | null = null
constructor(private readonly page: Page) {}
async init(): Promise<void> {
this.cdp = await this.page.context().newCDPSession(this.page)
await this.cdp.send('Performance.enable')
}
async dispose(): Promise<void> {
this.snapshot = null
if (this.cdp) {
try {
await this.cdp.send('Performance.disable')
} finally {
await this.cdp.detach()
this.cdp = null
}
}
}
private async getSnapshot(): Promise<PerfSnapshot> {
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
metrics: { name: string; value: number }[]
}
function get(name: string): number {
return metrics.find((m) => m.name === name)?.value ?? 0
}
return {
RecalcStyleCount: get('RecalcStyleCount'),
RecalcStyleDuration: get('RecalcStyleDuration'),
LayoutCount: get('LayoutCount'),
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
}
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
this.snapshot = await this.getSnapshot()
}
async stopMeasuring(name: string): Promise<PerfMeasurement> {
if (!this.snapshot) throw new Error('Call startMeasuring() first')
const after = await this.getSnapshot()
const before = this.snapshot
this.snapshot = null
function delta(key: keyof PerfSnapshot): number {
return after[key] - before[key]
}
return {
name,
durationMs: delta('Timestamp') * 1000,
styleRecalcs: delta('RecalcStyleCount'),
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
}
}
}

View File

@@ -1,11 +1,14 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -0,0 +1,49 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
export interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const TEMP_DIR = join('test-results', 'perf-temp')
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
} catch {
return
}
if (tempFiles.length === 0) return
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
)
const report: PerfReport = {
timestamp: new Date().toISOString(),
gitSha,
branch,
measurements
}
writeFileSync(
join('test-results', 'perf-metrics.json'),
JSON.stringify(report, null, 2)
)
}

View File

@@ -3,6 +3,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
@@ -63,6 +64,99 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
test('undo/redo restores link topology with reroutes and floating links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const readTopology = () =>
comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
return {
links: graph.links.size,
floatingLinks: graph.floatingLinks.size,
reroutes: graph.reroutes.size,
serialised: graph.serialize()
}
})
const baseline = await readTopology()
await beforeChange(comfyPage)
await comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
const firstLink = graph.links.values().next().value
if (!firstLink) throw new Error('Expected at least one link')
const reroute = graph.createReroute(
[firstLink.id * 5, firstLink.id * 3],
firstLink
)
graph.addFloatingLink(firstLink.toFloating('output', reroute.id))
})
await afterChange(comfyPage)
const mutated = await readTopology()
expect(mutated.floatingLinks).toBeGreaterThan(baseline.floatingLinks)
expect(mutated.reroutes).toBeGreaterThan(baseline.reroutes)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.undo()
})
const afterUndo = await readTopology()
expect(afterUndo.links).toBe(baseline.links)
expect(afterUndo.floatingLinks).toBe(baseline.floatingLinks)
expect(afterUndo.reroutes).toBe(baseline.reroutes)
expect(afterUndo.serialised).toEqual(baseline.serialised)
await comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.redo()
})
const afterRedo = await readTopology()
expect(afterRedo.links).toBe(mutated.links)
expect(afterRedo.floatingLinks).toBe(mutated.floatingLinks)
expect(afterRedo.reroutes).toBe(mutated.reroutes)
expect(afterRedo.serialised).toEqual(mutated.serialised)
})
test('read-through accessors stay in sync for links, floating links, and reroutes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const parity = await comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
const firstLink = graph.links.values().next().value
if (!firstLink) throw new Error('Expected at least one link')
const reroute = graph.createReroute(
[firstLink.id * 7, firstLink.id * 4],
firstLink
)
const floatingLink = firstLink.toFloating('output', reroute.id)
graph.addFloatingLink(floatingLink)
return {
normalLinkMatches:
graph.getLink(firstLink.id) === graph.links.get(firstLink.id),
floatingLinkRequiresExplicitProjection:
graph.getLink(floatingLink.id) === undefined &&
graph.floatingLinks.get(floatingLink.id) !== undefined,
rerouteMatches:
graph.getReroute(reroute.id) === graph.reroutes.get(reroute.id)
}
})
expect(parity.normalLinkMatches).toBe(true)
expect(parity.floatingLinkRequiresExplicitProjection).toBe(true)
expect(parity.rerouteMatches).toBe(true)
})
})
test('Can group multiple change actions into a single transaction', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

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

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,132 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Let the canvas idle for 2 seconds — no user interaction.
// Measures baseline style recalcs from reactive state + render loop.
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
recordMeasurement(m)
console.log(
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('canvas mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Sweep mouse across the canvas — crosses nodes, empty space, slots
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
recordMeasurement(m)
console.log(
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
// Load default workflow which has DOM widgets (text inputs, combos)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Select and deselect nodes rapidly to trigger clipping recalculation
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
// Click on canvas area (nodes occupy various positions)
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
test('subgraph idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-idle')
recordMeasurement(m)
console.log(
`Subgraph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('subgraph-mouse-sweep')
recordMeasurement(m)
console.log(
`Subgraph mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-dom-widget-clipping')
recordMeasurement(m)
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -87,9 +87,9 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
// This should show the current renamed value and stay aligned with slot identity.
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
expect(dialogValue).toBe(afterFirstRename.name)
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')

View File

@@ -555,6 +555,74 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 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: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 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: 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: 62 KiB

After

Width:  |  Height:  |  Size: 63 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: 59 KiB

After

Width:  |  Height:  |  Size: 60 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: 94 KiB

After

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

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 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: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,6 +1,7 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
@@ -22,7 +23,9 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const
const settings = {
@@ -109,6 +112,28 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Tailwind CSS v4 linting (class ordering, duplicates, conflicts, etc.)
betterTailwindcss.configs.recommended,
{
settings: {
'better-tailwindcss': {
entryPoint: 'packages/design-system/src/css/style.css'
}
},
rules: {
// Off: requires whitelisting non-Tailwind classes (PrimeIcons, custom CSS)
'better-tailwindcss/no-unknown-classes': 'off',
// Off: may conflict with oxfmt formatting
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/enforce-consistent-class-order': 'off',
// Off: large batch change (v3→v4 renames like rounded→rounded-sm),
// enable and apply with `eslint --fix` in a follow-up PR
'better-tailwindcss/enforce-canonical-classes': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/no-deprecated-classes': 'off'
}
},
// Disables ESLint rules that conflict with formatters
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types

View File

@@ -41,7 +41,9 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml'
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.5",
"version": "1.41.12",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -146,6 +146,7 @@
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-better-tailwindcss": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -199,7 +199,7 @@
#3e1ffc 65.17%,
#009dff 103.86%
),
var(--color-button-surface, #2d2e32);
linear-gradient(var(--color-button-surface, #2d2e32));
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -358,26 +358,6 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
var(--color-button-surface, #2d2e32);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -490,7 +470,6 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-background: var(--modal-card-background);
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
@@ -634,6 +613,14 @@
}
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5693 3L9.67677 14.099C9.59169 14.292 9.33927 14.3393 9.19012 14.1901L5.5 10.5M14.5693 3L1.65457 8.23468C1.40927 8.33411 1.40355 8.67936 1.64543 8.78686L5.5 10.5M14.5693 3L5.5 10.5M5.5 10.5C5.66712 10.5669 5.37259 10.3728 5.5 10.5ZM5.5 10.5C5.62741 10.6272 5.43279 10.333 5.5 10.5ZM5.5 10.5V13.5L7 12" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isPreviewableMediaType,
truncateFilename
@@ -198,6 +202,147 @@ describe('formatUtil', () => {
})
})
describe('getFilenameDetails', () => {
it('splits simple filenames into name and suffix', () => {
expect(getFilenameDetails('file.txt')).toEqual({
filename: 'file',
suffix: 'txt'
})
})
it('handles filenames with multiple dots', () => {
expect(getFilenameDetails('my.file.name.png')).toEqual({
filename: 'my.file.name',
suffix: 'png'
})
})
it('handles filenames without extension', () => {
expect(getFilenameDetails('README')).toEqual({
filename: 'README',
suffix: null
})
})
it('recognises .app.json as a compound extension', () => {
expect(getFilenameDetails('workflow.app.json')).toEqual({
filename: 'workflow',
suffix: 'app.json'
})
})
it('recognises .app.json case-insensitively', () => {
expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({
filename: 'Workflow',
suffix: 'app.json'
})
})
it('handles regular .json files normally', () => {
expect(getFilenameDetails('workflow.json')).toEqual({
filename: 'workflow',
suffix: 'json'
})
})
it('treats bare .app.json as a dotfile without basename', () => {
expect(getFilenameDetails('.app.json')).toEqual({
filename: '.app',
suffix: 'json'
})
})
})
describe('getPathDetails', () => {
it('splits a path with .app.json extension', () => {
const result = getPathDetails('workflows/test.app.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.app.json',
filename: 'test',
suffix: 'app.json'
})
})
it('splits a path with .json extension', () => {
const result = getPathDetails('workflows/test.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.json',
filename: 'test',
suffix: 'json'
})
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
})
it('appends .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test', false)).toBe('test.json')
})
it('replaces .json with .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json')
})
it('replaces .app.json with .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json')
})
it('leaves .app.json unchanged when isApp is true', () => {
expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json')
})
it('leaves .json unchanged when isApp is false', () => {
expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json')
})
it('handles case-insensitive extensions', () => {
expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json')
expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json')
})
})
describe('ensureWorkflowSuffix', () => {
it('appends suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json')
})
it('does not double-append when suffix already present', () => {
expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json')
})
it('appends compound suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json')
})
it('does not double-append compound suffix', () => {
expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .json with .app.json when suffix is app.json', () => {
expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .app.json with .json when suffix is json', () => {
expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json')
})
it('handles case-insensitive extension detection', () => {
expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json')
expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe(
'file.app.json'
)
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)

View File

@@ -26,13 +26,44 @@ export function formatCamelCase(str: string): string {
return processedWords.join(' ')
}
// Metadata cannot be associated with workflows, so extension encodes the mode.
const JSON_SUFFIX = 'json'
const APP_JSON_SUFFIX = `app.${JSON_SUFFIX}`
const JSON_EXT = `.${JSON_SUFFIX}`
const APP_JSON_EXT = `.${APP_JSON_SUFFIX}`
export function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
if (!path.toLowerCase().endsWith(JSON_EXT)) {
path += JSON_EXT
}
return path
}
export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX
export function getWorkflowSuffix(
suffix: string | null | undefined
): WorkflowSuffix {
return suffix === APP_JSON_SUFFIX ? APP_JSON_SUFFIX : JSON_SUFFIX
}
export function appendWorkflowJsonExt(path: string, isApp: boolean): string {
return ensureWorkflowSuffix(path, isApp ? APP_JSON_SUFFIX : JSON_SUFFIX)
}
export function ensureWorkflowSuffix(
name: string,
suffix: WorkflowSuffix
): string {
const lower = name.toLowerCase()
if (lower.endsWith(APP_JSON_EXT)) {
name = name.slice(0, -APP_JSON_EXT.length)
} else if (lower.endsWith(JSON_EXT)) {
name = name.slice(0, -JSON_EXT.length)
}
return name + '.' + suffix
}
export function highlightQuery(
text: string,
query: string,
@@ -96,19 +127,27 @@ export function formatCommitHash(value: string): string {
/**
* Returns various filename components.
* Recognises compound extensions like `.app.json`.
* Example:
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
* - fullFilename: 'file.txt' → { filename: 'file', suffix: 'txt' }
* - fullFilename: 'file.app.json' → { filename: 'file', suffix: 'app.json' }
*/
export function getFilenameDetails(fullFilename: string) {
if (fullFilename.includes('.')) {
const lower = fullFilename.toLowerCase()
if (
lower.endsWith(APP_JSON_EXT) &&
fullFilename.length > APP_JSON_EXT.length
) {
return {
filename: fullFilename.split('.').slice(0, -1).join('.'),
suffix: fullFilename.split('.').pop() ?? null
filename: fullFilename.slice(0, -APP_JSON_EXT.length),
suffix: APP_JSON_SUFFIX
}
} else {
return { filename: fullFilename, suffix: null }
}
const dotIndex = fullFilename.lastIndexOf('.')
if (dotIndex <= 0) return { filename: fullFilename, suffix: null }
return {
filename: fullFilename.slice(0, dotIndex),
suffix: fullFilename.slice(dotIndex + 1)
}
}

View File

@@ -36,7 +36,18 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
},
{
name: 'performance',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 60_000,
grep: /@perf/,
fullyParallel: false
},
{

75
pnpm-lock.yaml generated
View File

@@ -180,6 +180,9 @@ catalogs:
eslint-import-resolver-typescript:
specifier: ^4.4.4
version: 4.4.4
eslint-plugin-better-tailwindcss:
specifier: ^4.3.1
version: 4.3.1
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1
@@ -636,6 +639,9 @@ importers:
eslint-import-resolver-typescript:
specifier: 'catalog:'
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
@@ -1916,6 +1922,10 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/css-tree@3.6.9':
resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5314,6 +5324,19 @@ packages:
eslint-import-resolver-webpack:
optional: true
eslint-plugin-better-tailwindcss@4.3.1:
resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
oxlint: ^1.35.0
tailwindcss: ^3.3.0 || ^4.1.17
peerDependenciesMeta:
eslint:
optional: true
oxlint:
optional: true
eslint-plugin-import-x@4.16.1:
resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6558,6 +6581,9 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdn-data@2.23.0:
resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -7780,6 +7806,10 @@ packages:
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -7788,6 +7818,10 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-csstree@0.1.4:
resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==}
engines: {node: '>=18.18'}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -7918,6 +7952,10 @@ packages:
ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -9708,6 +9746,11 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/css-tree@3.6.9':
dependencies:
mdn-data: 2.23.0
source-map-js: 1.2.1
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.14.0
@@ -13457,6 +13500,23 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
enhanced-resolve: 5.19.0
jiti: 2.6.1
synckit: 0.11.12
tailwind-csstree: 0.1.4
tailwindcss: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
transitivePeerDependencies:
- typescript
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.56.0
@@ -14893,6 +14953,8 @@ snapshots:
mdn-data@2.12.2: {}
mdn-data@2.23.0: {}
mdurl@2.0.0: {}
media-encoder-host-broker@8.0.19:
@@ -16571,6 +16633,10 @@ snapshots:
'@pkgr/core': 0.2.9
tslib: 2.8.1
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.18.0
@@ -16581,6 +16647,8 @@ snapshots:
tagged-tag@1.0.0: {}
tailwind-csstree@0.1.4: {}
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.2.0):
@@ -16691,6 +16759,13 @@ snapshots:
ts-map@1.0.3: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.19.0
tapable: 2.3.0
tsconfig-paths: 4.2.0
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29

View File

@@ -61,6 +61,7 @@ catalog:
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.2.10

311
scripts/perf-report.ts Normal file
View File

@@ -0,0 +1,311 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { MetricStats } from './perf-stats'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
const HISTORY_DIR = 'temp/perf-history'
type MetricKey = 'styleRecalcs' | 'layouts' | 'taskDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' }
]
function groupByName(
measurements: PerfMeasurement[]
): Map<string, PerfMeasurement[]> {
const map = new Map<string, PerfMeasurement[]>()
for (const m of measurements) {
const list = map.get(m.name) ?? []
list.push(m)
map.set(m.name, list)
}
return map
}
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
} catch {
console.warn(`Skipping malformed perf history: ${filePath}`)
}
}
return reports
}
function getHistoricalStats(
reports: PerfReport[],
testName: string,
metric: MetricKey
): MetricStats {
const values: number[] = []
for (const r of reports) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
const mean =
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
values.push(mean)
}
}
return computeStats(values)
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
if (pct === null) return '—'
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(0)}%`
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number {
return samples.reduce((sum, s) => sum + s[key], 0) / samples.length
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historical: PerfReport[]
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
const flaggedRows: string[] = []
const allRows: string[] = []
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
}
}
}
if (flaggedRows.length > 0) {
lines.push(
`⚠️ **${flaggedRows.length} regression${flaggedRows.length > 1 ? 's' : ''} detected**`,
'',
...tableHeader,
...flaggedRows,
''
)
} else {
lines.push('No regressions detected.', '')
}
lines.push(
`<details><summary>All metrics</summary>`,
'',
...tableHeader,
...allRows,
'',
'</details>',
''
)
lines.push(
`<details><summary>Historical variance (last ${historical.length} runs)</summary>`,
'',
'| Metric | μ | σ | CV |',
'|--------|---|---|-----|'
)
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const stats = getHistoricalStats(historical, testName, key)
if (stats.n < 2) continue
const cv = computeCV(stats)
lines.push(
`| ${testName}: ${label} | ${formatValue(stats.mean, unit)} | ${formatValue(stats.stddev, unit)} | ${cv.toFixed(1)}% |`
)
}
}
lines.push('', '</details>')
return lines
}
function renderColdStartReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historicalCount: number
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
return lines
}
function renderNoBaselineReport(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const lines: string[] = []
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const [testName, prSamples] of prGroups) {
const prMean = (key: MetricKey) =>
prSamples.reduce((sum, s) => sum + s[key], 0) / prSamples.length
lines.push(
`| ${testName}: style recalcs | ${prMean('styleRecalcs').toFixed(0)} |`
)
lines.push(`| ${testName}: layouts | ${prMean('layouts').toFixed(0)} |`)
lines.push(
`| ${testName}: task duration | ${prMean('taskDurationMs').toFixed(0)}ms |`
)
const heapMean =
prSamples.reduce((sum, s) => sum + s.heapDeltaBytes, 0) / prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
}
function main() {
if (!existsSync(CURRENT_PATH)) {
process.stdout.write(
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
)
process.exit(0)
}
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const historical = loadHistoricalReports()
const prGroups = groupByName(current.measurements)
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
} else if (baseline) {
lines.push(...renderColdStartReport(prGroups, baseline, historical.length))
} else {
lines.push(...renderNoBaselineReport(prGroups))
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push('```')
lines.push('\n</details>')
process.stdout.write(lines.join('\n') + '\n')
}
main()

133
scripts/perf-stats.test.ts Normal file
View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
describe('computeStats', () => {
it('returns zeros for empty array', () => {
const stats = computeStats([])
expect(stats).toEqual({ mean: 0, stddev: 0, min: 0, max: 0, n: 0 })
})
it('returns value with zero stddev for single element', () => {
const stats = computeStats([42])
expect(stats).toEqual({ mean: 42, stddev: 0, min: 42, max: 42, n: 1 })
})
it('computes correct stats for known values', () => {
// Values: [2, 4, 4, 4, 5, 5, 7, 9]
// Mean = 5, sample variance ≈ 4.57, sample stddev ≈ 2.14
const stats = computeStats([2, 4, 4, 4, 5, 5, 7, 9])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBeCloseTo(2.138, 2)
expect(stats.min).toBe(2)
expect(stats.max).toBe(9)
expect(stats.n).toBe(8)
})
it('uses sample stddev (n-1 denominator)', () => {
// [10, 20] → mean=15, variance=(25+25)/1=50, stddev≈7.07
const stats = computeStats([10, 20])
expect(stats.mean).toBe(15)
expect(stats.stddev).toBeCloseTo(7.071, 2)
expect(stats.n).toBe(2)
})
it('handles identical values', () => {
const stats = computeStats([5, 5, 5, 5])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBe(0)
})
})
describe('zScore', () => {
it('returns null when stddev is 0', () => {
const stats = computeStats([5, 5, 5])
expect(zScore(10, stats)).toBeNull()
})
it('returns null when n < 2', () => {
const stats = computeStats([5])
expect(zScore(10, stats)).toBeNull()
})
it('computes correct z-score', () => {
const stats = { mean: 100, stddev: 10, min: 80, max: 120, n: 5 }
expect(zScore(120, stats)).toBe(2)
expect(zScore(80, stats)).toBe(-2)
expect(zScore(100, stats)).toBe(0)
})
})
describe('classifyChange', () => {
it('returns noisy when CV > 50%', () => {
expect(classifyChange(3, 60)).toBe('noisy')
expect(classifyChange(-3, 51)).toBe('noisy')
})
it('does not classify as noisy when CV is exactly 50%', () => {
expect(classifyChange(3, 50)).toBe('regression')
expect(classifyChange(-3, 50)).toBe('improvement')
})
it('returns neutral when z is null', () => {
expect(classifyChange(null, 10)).toBe('neutral')
})
it('returns regression when z > 2', () => {
expect(classifyChange(2.1, 10)).toBe('regression')
expect(classifyChange(5, 10)).toBe('regression')
})
it('returns improvement when z < -2', () => {
expect(classifyChange(-2.1, 10)).toBe('improvement')
expect(classifyChange(-5, 10)).toBe('improvement')
})
it('returns neutral when z is within [-2, 2]', () => {
expect(classifyChange(0, 10)).toBe('neutral')
expect(classifyChange(1.9, 10)).toBe('neutral')
expect(classifyChange(-1.9, 10)).toBe('neutral')
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
})
describe('formatSignificance', () => {
it('formats regression with z-score and emoji', () => {
expect(formatSignificance('regression', 3.2)).toBe('⚠️ z=3.2')
})
it('formats improvement with z-score without emoji', () => {
expect(formatSignificance('improvement', -2.5)).toBe('z=-2.5')
})
it('formats noisy as descriptive text', () => {
expect(formatSignificance('noisy', null)).toBe('variance too high')
})
it('formats neutral with z-score without emoji', () => {
expect(formatSignificance('neutral', 0.5)).toBe('z=0.5')
})
it('formats neutral without z-score as dash', () => {
expect(formatSignificance('neutral', null)).toBe('—')
})
})
describe('isNoteworthy', () => {
it('returns true for regressions', () => {
expect(isNoteworthy('regression')).toBe(true)
})
it('returns false for non-regressions', () => {
expect(isNoteworthy('improvement')).toBe(false)
expect(isNoteworthy('neutral')).toBe(false)
expect(isNoteworthy('noisy')).toBe(false)
})
})

63
scripts/perf-stats.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface MetricStats {
mean: number
stddev: number
min: number
max: number
n: number
}
export function computeStats(values: number[]): MetricStats {
const n = values.length
if (n === 0) return { mean: 0, stddev: 0, min: 0, max: 0, n: 0 }
if (n === 1)
return { mean: values[0], stddev: 0, min: values[0], max: values[0], n: 1 }
const mean = values.reduce((a, b) => a + b, 0) / n
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
return {
mean,
stddev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
n
}
}
export function zScore(value: number, stats: MetricStats): number | null {
if (stats.stddev === 0 || stats.n < 2) return null
return (value - stats.mean) / stats.stddev
}
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
export function classifyChange(
z: number | null,
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'
}
export function formatSignificance(
sig: Significance,
z: number | null
): string {
switch (sig) {
case 'regression':
return `⚠️ z=${z!.toFixed(1)}`
case 'improvement':
return `z=${z!.toFixed(1)}`
case 'noisy':
return 'variance too high'
case 'neutral':
return z !== null ? `z=${z.toFixed(1)}` : '—'
}
}
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}

View File

@@ -51,7 +51,6 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -18,14 +18,14 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="sidebarStateKey"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
"
:class="
sidebarLocation === 'left'
@@ -35,8 +35,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -54,7 +56,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -62,7 +64,7 @@
layout="vertical"
:pt:gutter="
cn(
'rounded-tl-lg rounded-tr-lg ',
'rounded-tl-lg rounded-tr-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
@@ -85,7 +87,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
"
:class="
sidebarLocation === 'right'
@@ -95,8 +97,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -123,6 +127,13 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -144,11 +155,17 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -169,7 +186,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
@@ -296,11 +296,13 @@ describe('TopMenuSection', () => {
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
qpoV2Enabled: boolean,
showRunProgressBar = true
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
@@ -332,6 +334,19 @@ describe('TopMenuSection', () => {
).toBe(false)
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')

View File

@@ -56,43 +56,6 @@
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -127,13 +90,15 @@
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<QueueInlineProgressSummary
:hidden="shouldHideInlineProgressSummary"
/>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
:hidden="shouldHideInlineProgressSummary"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
@@ -146,14 +111,11 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -163,16 +125,14 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -185,17 +145,11 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const { t, n } = useI18n()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -208,25 +162,19 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
() =>
isQueuePanelV2Enabled.value &&
isActionbarEnabled.value &&
isRunProgressBarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value
@@ -241,24 +189,12 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -281,27 +217,6 @@ onMounted(() => {
}
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -0,0 +1,101 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import { i18n } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
showRunProgressBar: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.Queue.QPOV2') return true
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
return undefined
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
props: {
topMenuContainer,
queueOverlayExpanded: false
},
global: {
plugins: [pinia, i18n],
stubs: {
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
},
Panel: {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})
return {
wrapper,
topMenuContainer
}
}
describe('ComfyActionbar', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
localStorage.clear()
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
})

View File

@@ -42,12 +42,44 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:hidden="shouldHideInlineProgress"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -65,18 +97,24 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -90,16 +128,20 @@ const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const { t, n } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
@@ -283,10 +325,19 @@ const onMouseLeaveDropZone = () => {
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (
!visible.value ||
!isQueuePanelV2Enabled.value ||
!isRunProgressBarEnabled.value
) {
return null
}
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {
@@ -315,11 +366,58 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const actionbarClass = computed(() =>
cn(

View File

@@ -8,31 +8,29 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
function openTemplates() {
@@ -43,7 +41,7 @@ function openTemplates() {
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button>
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
@@ -52,16 +50,21 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
@@ -70,7 +73,7 @@ function openTemplates() {
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilderMode"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
@@ -101,9 +104,7 @@ function openTemplates() {
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -60,6 +60,7 @@ import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -70,7 +71,6 @@ import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
@@ -78,7 +78,7 @@ interface Props {
isActive?: boolean
}
const { item, isActive = false } = defineProps<Props>()
const { item, isActive } = defineProps<Props>()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -107,9 +107,10 @@ const rename = async (
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
const suffix = getWorkflowSuffix(workflowStore.activeWorkflow.suffix)
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + appendJsonExt(newName)
ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
)
} catch (error) {
console.error(error)

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
const { graph } = canvas
if (!canvas || !graph) return
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (!e) return
canvas.adjustMouseEvent(e)
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
if (!node) return
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
const height =
(widget.computedHeight !== undefined
? widget.computedHeight - 4
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
}
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
function nodeToDisplayTuple(
n: LGraphNode
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="text-muted-foreground text-sm p-1 pointer-events-none">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@pointerdown="handleDown"
@pointermove="hoveringSelectable = !!getHovered($event)"
@click="handleClick"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>
</template>
</TransformPane>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,62 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
<span class="inline-flex items-center gap-2">
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
<i
aria-hidden="true"
class="icon-[lucide--circle-check-big] size-4 text-green-500"
/>
</span>
</template>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppBody')
: $t('builderToolbar.defaultModeAppliedGraphBody')
}}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppPrompt')
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
}}
</p>
<template #footer>
<template v-if="appliedAsApp">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
<template v-else>
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
</template>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
appliedAsApp: boolean
}>()
defineEmits<{
viewApp: []
close: []
}>()
</script>

View File

@@ -1,5 +1,7 @@
<template>
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
<div
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -11,6 +13,7 @@
</h2>
</div>
<Button
v-if="showClose"
variant="muted-textonly"
class="-mr-1"
:aria-label="$t('g.close')"
@@ -21,7 +24,7 @@
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<div class="flex flex-1 flex-col gap-4 px-4 py-4">
<slot />
</div>
@@ -35,6 +38,10 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { showClose = true } = defineProps<{
showClose?: boolean
}>()
defineEmits<{
close: []
}>()

View File

@@ -0,0 +1,147 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/composables/useAppMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockShowDialog = vi.hoisted(() => vi.fn())
const mockState = {
mode: 'builder:select' as AppMode,
settingView: false
}
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: computed(() => mockState.mode),
isBuilderMode: ref(true),
setMode: mockSetMode
})
}))
const mockHasOutputs = ref(true)
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
exitBuilder: mockExitBuilder,
hasOutputs: mockHasOutputs,
$id: 'appMode'
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
dialogStack: []
})
}))
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
useAppSetDefaultView: () => ({
settingView: computed(() => mockState.settingView),
showDialog: mockShowDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
builderMenu: { exitAppBuilder: 'Exit app builder' },
g: { back: 'Back', next: 'Next' }
}
}
})
describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockState.settingView = false
})
function mountComponent() {
return mount(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
}
})
}
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
const buttons = wrapper.findAll('button')
return {
exit: buttons[0],
back: buttons[1],
next: buttons[2]
}
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeUndefined()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
it('calls setMode on back click', async () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { exit } = getButtons(mountComponent())
await exit.trigger('click')
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,63 @@
<template>
<nav
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
</nav>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
hasOutputs
})
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
dialogStore.dialogStack.length === 0 &&
isBuilderMode.value
) {
e.preventDefault()
e.stopPropagation()
onExitBuilder()
}
})
function onExitBuilder() {
void appModeStore.exitBuilder()
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<Popover :show-arrow="false" class="min-w-56 p-3">
<template #button>
<button
:class="
cn(
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
'bg-secondary-background hover:bg-secondary-background-hover',
'data-[state=open]:bg-secondary-background-hover'
)
"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
>
<i class="icon-[lucide--hammer] size-4" />
<span class="text-sm font-medium">
{{ t('linearMode.appModeToolbar.appBuilder') }}
</span>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
</template>
</Popover>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
try {
await workflowService.saveWorkflow(workflow)
close()
} catch (error) {
toastErrorHandler(error)
}
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -1,51 +0,0 @@
<template>
<BuilderDialog @close="onClose">
<template #header-icon>
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
</template>
<template #title>
{{ $t('builderToolbar.saveSuccess') }}
</template>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
</p>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
</p>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
</p>
<template #footer>
<Button
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
size="lg"
@click="onClose"
>
{{ $t('g.close') }}
</Button>
<Button
v-if="savedAsApp && onViewApp"
variant="primary"
size="lg"
@click="onViewApp"
>
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
workflowName: string
savedAsApp: boolean
onViewApp?: () => void
onClose: () => void
}>()
</script>

View File

@@ -1,26 +1,23 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
:aria-label="t('builderToolbar.label')"
>
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<template v-for="(step, index) in steps" :key="step.id">
<button
:class="
cn(
stepClasses,
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
@click="navigateToStep(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -29,15 +26,19 @@
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Save -->
<!-- Default view -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</ConnectOutputPopover>
<button
@@ -45,61 +46,72 @@
:class="
cn(
stepClasses,
activeStep === 'save'
activeStep === 'setDefaultView'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
@click="navigateToStep('setDefaultView')"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectStep: BuilderToolbarStep<AppMode> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<AppMode> = {
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const saveStep: BuilderToolbarStep<'save'> = {
id: 'save',
title: t('builderToolbar.save'),
subtitle: t('builderToolbar.saveDescription'),
icon: 'icon-[lucide--cloud-upload]'
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToSelect') }}
{{ t('builderToolbar.switchToOutputs') }}
</Button>
</PopoverClose>
</template>

View File

@@ -1,32 +1,16 @@
<template>
<BuilderDialog @close="onClose">
<BuilderDialog @close="$emit('close')">
<template #title>
{{ $t('builderToolbar.saveAs') }}
{{ $t('builderToolbar.defaultViewTitle') }}
</template>
<!-- Filename -->
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
/>
</div>
<!-- Save as type -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.saveAsLabel') }}
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in saveTypeOptions"
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
@@ -61,23 +45,18 @@
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="onClose">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="onSave(filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref, useId } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -87,17 +66,18 @@ import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { defaultFilename, onSave, onClose } = defineProps<{
defaultFilename: string
onSave: (filename: string, openAsApp: boolean) => void
onClose: () => void
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
}>()
const inputId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(true)
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const saveTypeOptions = [
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',

View File

@@ -0,0 +1,40 @@
<template>
<BuilderDialog :show-close="false">
<template #title>
{{ $t('builderToolbar.emptyWorkflowTitle') }}
</template>
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
</p>
</div>
<template #footer>
<Button
variant="muted-textonly"
size="lg"
@click="$emit('backToWorkflow')"
>
{{ $t('builderToolbar.backToWorkflow') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('builderToolbar.loadTemplate') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineEmits<{
backToWorkflow: []
loadTemplate: []
}>()
</script>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const { rename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
remove?: () => void
}>()
const entries = computed(() => {
const items = []
if (rename)
items.push({
label: t('g.rename'),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
items.push({
label: t('g.delete'),
command: remove,
icon: 'icon-[lucide--trash-2]'
})
return items
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe gap-2">
<div
class="mr-auto flex-[4_1_0%] max-w-max min-w-0 truncate drag-handle inline"
v-text="title"
/>
<div
class="flex-[2_1_0%] max-w-max min-w-0 truncate text-muted-foreground text-end drag-handle inline"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
</template>

View File

@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as {
initialMode?: string | null
changeTracker?: { checkState: () => void }
} | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
const mockSetMode = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
it('shows confirmation dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.key).toBe('builder-default-view-applied')
expect(confirmCall.props.appliedAsApp).toBe(true)
})
it('passes appliedAsApp false to confirmation dialog when graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.props.appliedAsApp).toBe(false)
})
})
describe('applied dialog', () => {
function applyAndGetConfirmDialog(openAsApp: boolean) {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
applyCall.props.onApply(openAsApp)
return mockDialogService.showLayoutDialog.mock.calls[1][0]
}
it('onViewApp sets mode to app and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onViewApp()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
mockDialogStore.closeDialog.mockClear()
confirmCall.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
})
})
})

View File

@@ -0,0 +1,71 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
closeDialog()
showAppliedDialog(openAsApp)
}
function showAppliedDialog(appliedAsApp: boolean) {
dialogService.showLayoutDialog({
key: APPLIED_DIALOG_KEY,
component: BuilderDefaultModeAppliedDialogContent,
props: {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
setMode('app')
},
onClose: closeAppliedDialog
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function closeAppliedDialog() {
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -1,124 +0,0 @@
import { watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
try {
workflow.changeTracker?.checkState()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
resetSaving()
}
return
}
showSaveDialog(workflow.filename)
}
function showSaveDialog(defaultFilename: string) {
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename,
onSave: handleSave,
onClose: handleCancelSave
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function handleCancelSave() {
closeSaveDialog()
resetSaving()
}
async function handleSave(filename: string, openAsApp: boolean) {
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function closeSaveDialog() {
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
}
function closeSuccessDialog() {
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
resetSaving()
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
}
}

View File

@@ -0,0 +1,79 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:arrange',
'setDefaultView'
] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed<BuilderStepId>(() => {
if (settingView.value) return 'setDefaultView'
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:inputs'
})
const activeStepIndex = computed(() =>
BUILDER_STEPS.indexOf(activeStep.value)
)
const isFirstStep = computed(() => activeStepIndex.value === 0)
const isLastStep = computed(() => {
if (!options?.hasOutputs?.value)
return activeStepIndex.value >= ARRANGE_INDEX
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
showDialog()
} else {
setMode(stepId)
}
}
function goBack() {
if (isFirstStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
activeStep,
activeStepIndex,
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext
}
}

View File

@@ -0,0 +1,44 @@
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
const DIALOG_KEY = 'builder-empty-workflow'
export function useEmptyWorkflowDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
function show(options: {
onEnterBuilder: () => void
onDismiss: () => void
}) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: EmptyWorkflowDialogContent,
props: {
onBackToWorkflow: () => {
closeDialog()
options.onDismiss()
},
onLoadTemplate: () => {
closeDialog()
templateSelectorDialog.show('appbuilder', {
afterClose: () => {
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
}
})
}
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
return { show }
}

View File

@@ -40,6 +40,7 @@ import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
@@ -88,6 +89,7 @@ const handleFileUpload = async (event: Event) => {
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {

View File

@@ -1,7 +1,11 @@
<template>
<span
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -0,0 +1,59 @@
<script setup lang="ts" generic="T">
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
import { DraggableList } from '@/scripts/ui/draggableList'
const modelValue = defineModel<T[]>({ required: true })
const draggableList = ref<DraggableList>()
const draggableItems = useTemplateRef('draggableItems')
watchPostEffect(() => {
void modelValue.value.length
draggableList.value?.dispose()
if (!draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const itemList = modelValue.value
const [item] = itemList.splice(oldPosition, 1)
itemList.splice(newPosition, 0, item)
modelValue.value = [...itemList]
}
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
const { t } = useI18n()
defineOptions({
inheritAttrs: false
})
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</script>
<template>
<DropdownMenuSeparator
v-if="item.separator"
class="h-[1px] bg-border-subtle m-1"
/>
<DropdownMenuSub v-else-if="item.items">
<DropdownMenuSubTrigger
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.items?.length"
>
{{ item.label }}
<i class="ml-auto icon-[lucide--chevron-right]" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:class="contentClass"
:side-offset="2"
:align-offset="-5"
>
<DropdownItem
v-for="(subitem, index) in item.items"
:key="toValue(subitem.label) ?? index"
:item="subitem"
:item-class
:content-class
/>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-if="item.new"
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
itemClass?: string
contentClass?: string
}>()
const itemClass = computed(() =>
cn(
'data-[highlighted]:bg-secondary-background-hover data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
itemProp
)
)
const contentClass = computed(() =>
cn(
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
contentProp
)
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -3,14 +3,18 @@
<Card>
<template #content>
<div class="flex flex-col items-center">
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
<h3>{{ title }}</h3>
<i
v-if="icon"
:class="icon"
style="font-size: 3rem; margin-bottom: 1rem"
/>
<h3 v-if="title">{{ title }}</h3>
<p :class="textClass" class="text-center whitespace-pre-line">
{{ message }}
</p>
<Button
v-if="buttonLabel"
variant="textonly"
:variant="buttonVariant ?? 'textonly'"
@click="$emit('action')"
>
{{ buttonLabel }}
@@ -25,14 +29,16 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '../ui/button/button.variants'
const props = defineProps<{
class?: string
icon?: string
title: string
title?: string
message: string
textClass?: string
buttonLabel?: string
buttonVariant?: ButtonVariants['variant']
}>()
defineEmits(['action'])
@@ -51,7 +57,6 @@ defineEmits(['action'])
}
.no-results-placeholder p {
color: var(--text-color-secondary);
margin-bottom: 1rem;
}
</style>

View File

@@ -8,7 +8,7 @@
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@@ -53,7 +53,7 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
@@ -11,17 +13,17 @@ const {
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>()
const badgeClass = computed(() =>
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
)
</script>
<template>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -9,12 +10,25 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -78,6 +92,7 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,6 +24,25 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -33,6 +52,15 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -41,15 +69,6 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -73,6 +92,7 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -93,9 +113,21 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -27,8 +28,13 @@ const { menuItems } = useWorkflowActionsMenu(
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
@@ -39,7 +45,7 @@ function handleOpen(open: boolean) {
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button">
<slot name="button" :has-unseen-items="hasUnseenItems">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
@@ -49,7 +55,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -60,6 +66,11 @@ function handleOpen(open: boolean) {
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>

View File

@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ label: 'Action', icon: 'pi pi-play', command }
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
@@ -57,6 +57,7 @@ describe('WorkflowActionsList', () => {
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
id: 'new-feature',
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
@@ -69,9 +70,27 @@ describe('WorkflowActionsList', () => {
expect(wrapper.text()).toContain('NEW')
})
it('does not render items with visible set to false', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden',
label: 'Hidden Item',
icon: 'pi pi-eye-slash',
command: vi.fn(),
visible: false
},
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
})
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)

View File

@@ -26,7 +26,7 @@ const {
/>
<component
:is="itemComponent"
v-else
v-else-if="item.visible !== false"
:disabled="item.disabled"
:class="
cn(

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,7 +77,8 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'

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