Compare commits

...

50 Commits

Author SHA1 Message Date
Johnpaul
5779972811 fix: address PR review feedback for keybinding presets
- Use file-input icon instead of download for import action
- Replace custom delete dialog with dialogService.confirm
- Add 3s timeout to all keybinding preset toast notifications
2026-03-16 17:45:40 +01:00
Johnpaul
1b32add744 fix: import preset now saves to storage and switches to it
Previously importPreset applied bindings but then reset to default,
discarding the preset identity. Now it persists the file and calls
switchPreset which handles the dirty check dialog.
2026-03-14 01:31:47 +01:00
Johnpaul
02bdbf885d fix: preset dropdown jumping on open
Remove disable-portal from Select so dropdown content portals out of
the header flex layout instead of causing a layout shift.
2026-03-13 15:13:50 +01:00
Johnpaul
ae3ba8762f fix: resolve merge conflicts with main
Merge origin/main into import/export-keybindings, combining preset
functionality with multi-keybinding support (context menu, expandable
rows, remove all keybindings dialog, reset all).
2026-03-13 15:04:06 +01:00
Johnpaul Chiwetelu
48d928fc9e feat: multi-keybinding support in settings panel (#9738)
## Summary

Adds VS Code-style multi-keybinding support to the Keybinding settings
panel. Commands can now have multiple keybindings displayed, expanded,
and individually managed.

- Fixes #1088

## Changes

### Store (`keybindingStore.ts`)
- `removeAllKeybindingsForCommand(commandId)` — unsets all bindings for
a command
- `updateSpecificKeybinding(old, new)` — replaces a single binding
without affecting others
- `resetKeybindingForCommand` — updated to restore **all** default
bindings, not just the first
- `isCommandKeybindingModified` — updated to compare full sorted sets of
bindings

### UI (`KeybindingPanel.vue`)
- **Data model**: `keybinding: KeybindingImpl | null` → `keybindings:
KeybindingImpl[]`
- **Multi-binding display**: shows up to 2 combos inline with `, `
separator, then `+ N more` badge
- **Expand/collapse**: click any row with 2+ bindings to expand
individual binding rows; chevron-right icon rotates on expand
- **Per-binding actions**: edit (pencil), reset, trash on each expanded
sub-row
- **Parent row actions**: `+`/trash for 2+ bindings, pencil/reset/trash
for 1, `+`/disabled for 0
- **Edit modes**: `edit` (replace specific binding via
`updateSpecificKeybinding`) and `add` (append via `addUserKeybinding`)
- **Right-click context menu**: Change keybinding, Add new, Reset to
default, Remove keybinding — with proper disabled states and lucide
icons
- **Remove all dialog**: confirmation via `showSmallLayoutDialog` with
`RemoveAllKeybindingsHeader`/`Content` components
- **Reset all dialog**: confirmation via `showConfirmDialog` before
resetting all keybindings to defaults
- **Double-click**: 0 bindings → add, 1 → edit, 2+ → no-op (single click
toggles expand)
- **Consistent alignment**: commands without chevron get `pl-5` padding
to align with those that have it

### Tests (`keybindingStore.test.ts`)
- 7 new tests covering `removeAllKeybindingsForCommand`,
`updateSpecificKeybinding`, multi-binding `isCommandKeybindingModified`,
and multi-binding `resetKeybindingForCommand`

### i18n (`main.json`)
- 11 new keys: removeAllKeybindingsTitle/Message, removeAll,
changeKeybinding, addNewKeybinding, resetToDefault, removeKeybinding,
nMoreKeybindings, resetAllKeybindingsTitle/Message, allKeybindingsReset

### New components
- `RemoveAllKeybindingsHeader.vue` — dialog header
- `RemoveAllKeybindingsContent.vue` — dialog body with Close/Remove all
buttons

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes (no new errors)
- [x] `pnpm vitest run src/platform/keybindings/` — 45 tests pass
- [x] CodeRabbit review — 0 findings
- [ ] Manual: open Settings → Keybindings, verify multi-binding commands
(e.g. Delete Selected Items, Zoom In) show multiple combos
- [ ] Manual: click row to expand, verify per-binding actions work
- [ ] Manual: right-click row, verify context menu actions
- [ ] Manual: click trash on 2+ binding command, verify "Remove all"
confirmation dialog
- [ ] Manual: click "Reset All" button, verify confirmation dialog
appears
- [ ] Manual: add/edit/remove individual bindings, verify persistence

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9738-feat-multi-keybinding-support-in-settings-panel-3206d73d365081e9b08bd3cfe21495f1)
by [Unito](https://www.unito.io)
2026-03-13 13:43:33 +00:00
Jin Yi
10b0350d01 feat: unify sidebar panel header layout with SidebarTopArea component (#9740)
## Summary

Unify the search bar + action buttons layout across all left sidebar
panels (Node Library, Workflows, Model Library, Media Assets) using a
shared `SidebarTopArea` presentation component.

## Changes

- **What**:
- Add `SidebarTopArea.vue` — layout component with `flex-1` default slot
(search) and `#actions` slot (buttons), plus optional `bottomDivider`
prop
- Replace raw `<button>` elements in Node Library with `<Button
variant="secondary" size="icon">`
- Replace reka-ui `TabsTrigger` with shared `Tab/TabList` component in
Node Library
- Move Media Assets tab list from hover-only `#tool-buttons` to
always-visible header below search area
- Unify spacing (`gap-2`, `p-2 2xl:px-4`) and divider styles across all
sidebar panels
- Remove unused `assetType` prop and header from
`AssetsSidebarGridView`/`AssetsSidebarListView`

## Review Focus

- `SidebarTopArea` API simplicity — just slots + one optional prop
- Node Library still requires `TabsRoot` in the body for reka-ui
`TabsContent` in child panels
- Media Assets tabs are now always visible instead of hover-only

[screen-capture
(1).webm](https://github.com/user-attachments/assets/fe1d8f7b-5674-4bb3-9842-569e4c3af6c9)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9740-feat-unify-sidebar-panel-header-layout-with-SidebarTopArea-component-3206d73d365081ea8ba7fd6ac54e0169)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 06:32:18 -07:00
Dante
5167a511ce fix: advanced widgets always visible regardless of setting (#9857)
## Summary

- Fixes a bug where widgets marked as `advanced` were always visible,
ignoring the "Always show advanced widgets on all nodes" setting
- Root cause: `extractWidgetDisplayOptions` in `useGraphNodeManager.ts`
read `widget.advanced` (always `undefined` on BaseWidget) instead of
`widget.options?.advanced` (where `litegraphService` actually sets the
flag)
- Consistent with how `hidden` is already read from
`widget.options.hidden` on the adjacent line

## Test plan

- [ ] Load a node with advanced inputs (e.g. `LTXVScheduler`)
- [ ] Verify `max_shift`, `base_shift`, `stretch`, `terminal` are hidden
when "Always show advanced widgets on all nodes" is disabled
- [ ] Verify they become visible when the setting is enabled or the
per-node toggle is clicked
- [ ] Verify the advanced toggle button appears on nodes with advanced
widgets

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9857-fix-advanced-widgets-always-visible-regardless-of-setting-3226d73d36508132b338d098fbf19433)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:25:03 -07:00
Christian Byrne
4bdf67ca21 fix: restore fork PR lint/format CI workflow (#9846)
## Problem

The lint/format CI workflow was broken for fork PRs in two ways:

### 1. Node version mismatch in setup-frontend action
The `setup-frontend` shared action (created in #8377) was missed when
Node version was standardized to `.nvmrc` in #9521. It still used
`node-version: 'lts/*'` instead of `node-version-file: '.nvmrc'`.

### 2. Fork PRs with lint issues silently passed CI
Fork PRs with auto-fixable lint/format issues got a **green checkmark**
despite having unfixed issues:
1. Auto-fix steps (`lint:fix`, `format`) fix issues in the workspace
2. `Commit changes` is correctly skipped for forks (can't push to fork
branches)
3. `Final validation` passes because it runs on the already-fixed
workspace
4. The `Comment on PR about manual fix needed` step tries to post a
comment via `actions/github-script`, but fork PRs have a read-only
`GITHUB_TOKEN` — the comment silently fails (`continue-on-error: true`)
5. **Result**: workflow reports success, contributor thinks their code
is clean

## Fix

- **setup-frontend**: Use `node-version-file: '.nvmrc'` instead of
`node-version: 'lts/*'`
- **ci-lint-format**: Replace the broken fork comment step with an
explicit `exit 1` that fails CI and prints clear fix instructions in the
log. This follows the principle from `.github/AGENTS.md`: fork PRs can't
post comments, so don't try.

## Testing
- [ ] Verify fork PRs with clean code still pass
- [ ] Verify fork PRs with lint issues now properly fail (instead of
silently passing)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9846-fix-restore-fork-PR-lint-format-CI-workflow-3226d73d3650811cb5bfe9f1f989cc0c)
by [Unito](https://www.unito.io)
2026-03-13 06:14:16 -07:00
Christian Byrne
e119383072 feat: select group children on click (#9149)
## Summary

Add a setting to select all children (nodes, reroutes, nested groups)
when clicking a group on the canvas.

## Changes

- **What**: New `LiteGraph.Group.SelectChildrenOnClick` boolean setting
(default: `false`). When enabled, selecting a group cascades `select()`
to all its `_children`, and deselecting cascades `deselect()`. Recursion
handles nested groups naturally. No double-move risk — the drag handler
already uses `skipChildren=true`. The setting is wired via `onChange` to
`canvas.groupSelectChildren`, keeping litegraph free of platform
imports.

## Review Focus

- The select/deselect cascading in `LGraphCanvas.select()` /
`deselect()` — verify no infinite recursion risk with deeply nested
groups.
- The `groupSelectChildren` property is set via the setting's `onChange`
callback on `LGraphCanvas.active_canvas` — confirm this covers canvas
re-creation scenarios.

## Screenshots (if applicable)

N/A — behavioral change behind a setting toggle.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9149-feat-select-group-children-on-click-3116d73d365081a1a7b8c82dea95b242)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 06:13:18 -07:00
John Haugeland
f4bf169b2f perf: remove deep: true from 3 hot watchers to reduce reactivity overhead (#9248)
All three watchers used { deep: true } unnecessarily because their
watched sources already produce new object/array references on change,
making deep traversal redundant:

- GraphView.vue: queueStore.tasks is a computed that spreads three
  shallowRef arrays into a new array each time. TaskItemImpl instances
  are immutable (readonly fields, replaced not mutated).

- DomWidget.vue: Replaced opaque widgetState deep watcher with explicit
  property deps (pos, size, zIndex, readonly, positionOverride). During
  60 FPS pan/zoom, Vue no longer walks the entire DomWidgetState object
  graph including the markRaw widget — only 5 leaf properties are
  checked. pos and size are new array literals each frame; the rest are
  primitives.

- GraphCanvas.vue: nodeLocationProgressStates is a computed returning a
  new Record on every execution progress event (nodeProgressStates is
  replaced wholesale via WebSocket handler).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9248-perf-remove-deep-true-from-3-hot-watchers-to-reduce-reactivity-overhead-3136d73d365081278f18da5a2eef6971)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-13 05:55:52 -07:00
Dante
07d5bd50f6 feat: extract SeedControlButton component (#9744)
<img width="1048" height="482" alt="스크린샷 2026-03-12 오전 9 11 56"
src="https://github.com/user-attachments/assets/68009980-097c-4736-b7c4-eb8f9a6f05be"
/>

## Summary
- Extract inline value control button from `WidgetWithControl` into
reusable `SeedControlButton` component
- Support `badge` (pill) and `button` (square) variants per Figma design
system spec
- Use native `<button>` element for proper a11y (works with Reka UI's
`PopoverTrigger as-child`)

## Test plan
- [x] Verify seed control button renders correctly on KSampler node's
seed widget
- [x] Verify popover opens on click and mode selection works
- [x] Verify all 4 modes display correct icon/text (shuffle, pencil-off,
+1, -1)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9744-feat-extract-SeedControlButton-component-3206d73d365081a3823cc19e48d205c1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-13 02:49:18 -07:00
Dante
c318cc4c14 feat: replace PrimeVue ColorPicker with custom component (#9647)
## Summary
- Replace PrimeVue `ColorPicker` with a custom component built on Reka
UI Popover
- New `ColorPicker` supports HSV saturation-value picking, hue/alpha
sliders, hex/rgba display toggle
- Simplify `WidgetColorPicker` by removing PrimeVue-specific
normalization logic
- Add Storybook stories for both `ColorPicker` and `WidgetColorPicker`

## Test plan
- [x] Unit tests pass (9 widget tests, 47 colorUtil tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify color picker visually in Storybook
- [ ] Test color picking in node widgets with hex/rgb/hsb formats

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-13 12:45:10 +09:00
Jin Yi
bfabf128ce fix: inline splash CSS to prevent SPA fallback breakage on cloud environments (#9849)
## Summary

Inline splash screen CSS into `index.html` to fix broken loading
animation on cloud/ephemeral environments.

## Changes

- **What**: On cloud/ephemeral environments (e.g.
`fe-pr-*.testenvs.comfy.org`), SPA fallback serves `index.html` for
unknown paths. The `<link href="splash.css">` request resolves to
`/cloud/splash.css`, which the server does not find as a static file —
so it returns `index.html` with `200 OK`. The browser receives HTML
instead of CSS, the CSS parser silently ignores it, and the splash
screen renders without any styles or animations.
- Inlined `splash.css` directly into `index.html` `<style>` block —
eliminates the external request entirely
- Moved `splash.css` to `src/assets/` for content-hashed Vite processing
as source of truth
  - Removed `public/splash.css`

## Review Focus

- The inline CSS is byte-for-byte identical to the original
`public/splash.css`
- `src/assets/splash.css` preserved as canonical source for future
changes

[screen-capture
(1).webm](https://github.com/user-attachments/assets/06729641-d1fd-47aa-9dd4-4acd28c2cfcf)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9849-fix-inline-splash-CSS-to-prevent-SPA-fallback-breakage-on-cloud-environments-3226d73d365081418741eb0944a74977)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 02:42:46 +00:00
Jin Yi
6a8f3ef1a1 fix: show download icon alongside file size in missing models dialog (#9850)
## Summary

Fix download icon not appearing when file size is successfully fetched
in the missing models dialog.

## Changes

- **What**: Restructured the `v-if/v-else-if` chain in
`MissingModelsContent.vue` so that file size and download icon render
together instead of being mutually exclusive. Previously, a successful
file size fetch would prevent the download button from rendering.

## Review Focus

The file size span and download/gated-link are now inside a shared
`<template v-else-if="model.isDownloadable">` block. File size uses
`v-if` (independent), while gated link and download button remain
`v-if/v-else` (mutually exclusive with each other).


[screen-capture.webm](https://github.com/user-attachments/assets/f2f04d52-265b-4d05-992e-0ffe9bf64026)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9850-fix-show-download-icon-alongside-file-size-in-missing-models-dialog-3226d73d365081fd943bcfdedda87c73)
by [Unito](https://www.unito.io)
2026-03-13 11:30:54 +09:00
Dante
649b9b3fe3 fix: standardize i18n pluralization to two-part format (#9371)
## Summary

- Converts redundant three-part pluralization patterns (`zero | singular
| plural`) to standard two-part format (`singular | plural`) across 11
locale files
- Only converts patterns where the zero form duplicates the singular or
plural form
- Retains three-part patterns where the zero form provides distinct
content (e.g. `"No items selected"`) or where the language
linguistically requires all three forms (Russian singular/paucal/plural)

## Context

Vue i18n selects plural forms based on the number of choices:

**3-part** `"{count} nodes | {count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 0 (zero) | "0 nodes" |
| 1 | 1 (singular) | "1 node" |
| 2+ | 2 (plural) | "N nodes" |

**2-part** `"{count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 1 (plural) | "0 nodes" |
| 1 | 0 (singular) | "1 node" |
| 2+ | 1 (plural) | "N nodes" |

Output is identical — the zero form was always a duplicate of the plural
form. This PR removes that redundancy.

## Changes

| Locale | Keys changed |
|--------|-------------|
| en, es, fr, pt-BR, ar, tr | 5 (`nodesCount`, `asset`, `errorCount`,
`downloadsFailed`, `exportFailed`) |
| ja, ko, fa | 3 (`asset`, `nodesCount`, `downloadsFailed`) |
| ru | 2 (`downloadsFailed`, `exportFailed`) |
| zh-TW | 2 (`nodesCount`, `downloadsFailed`) |

## Test plan

- [ ] Verify pluralization renders correctly for count=0, count=1,
count=2+ in affected UI areas
- [ ] Spot-check non-English locales (especially Russian which retains
3-part for linguistically distinct forms)

- Fixes #9277

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-13 10:16:59 +09:00
Hunter
d82bce90ea feat: bake frontend commit hash into build (#9832)
## Summary

Bake the frontend git commit hash into the build so it no longer needs
to be fetched from the server via `/api/system_stats`.

## Changes

- **What**: Add `__COMFYUI_FRONTEND_COMMIT__` build-time constant (via
Vite `define`) sourced from `git rev-parse HEAD` at build time. Falls
back to `"unknown"` if git is unavailable. `SystemStatsPanel` uses this
baked-in value for the "Frontend Version" row in cloud mode instead of
the server-provided `comfyui_frontend_version` field.

## Testing
Confirmed to display the actual commit.
<img width="1448" height="908" alt="Screenshot 2026-03-12 at 7 09 52 PM"
src="https://github.com/user-attachments/assets/2b42348a-5c3e-4509-aa84-1a259bba5f3f"
/>


## Review Focus

- The `getDisplayValue` override for `comfyui_frontend_version` —
cleanest way to swap the data source without restructuring the column
system.
- No cloud-side changes needed: the `sync-frontend-build` workflow
already checks out the frontend repo at the exact commit ref, so `git
rev-parse HEAD` returns the correct hash.
2026-03-12 21:07:07 -04:00
Luke Mino-Altherr
91e429a62f fix: use order-independent tag matching in asset browser categories (#9843)
## Summary

Fix asset browser sidebar missing categories because `typeCategories`
assumed a fixed tag order from the API.

## Changes

- **What**: Replace `tags[0] === 'models'` / `tags[1]` with
`tags.includes(MODELS_TAG)` and `flatMap`+`filter`, matching the pattern
used by `getAssetModelFolders` and `filterByCategory`.

## Review Focus

The API returns tags in arbitrary order (e.g. `['checkpoints',
'models']` instead of `['models', 'checkpoints']`). The old code
filtered out most assets, resulting in an empty sidebar. New test
validates arbitrary tag ordering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9843-fix-use-order-independent-tag-matching-in-asset-browser-categories-3216d73d365081b886f3d5ab1790e19d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-12 17:59:05 -07:00
John Haugeland
21edbd3ee5 fix: cap nodeProgressStatesByJob to prevent unbounded memory growth (#9249)
This has semi-significant performance impact if you use the same
workflow for more than a day. I left Comfy running with a mouse jiggler
and this reduced my instance to a crawl after about an hour.

`nodeProgressStatesByJob` accumulated an entry for every job that ever
executed during a session. In long-running sessions this grew without
bound, since entries were only removed for the active job on
`resetExecutionState`.

Add `MAX_PROGRESS_JOBS` (1000) and `evictOldProgressJobs()`, called
after each `handleProgressState` update. When the map exceeds the limit,
the oldest entries (by ES2015+ insertion order) are pruned — keeping
only the most recent 1000. This mirrors the pattern used by
assetsStore's `MAX_HISTORY_ITEMS`.

Also adds tests for:
- nodeLocationProgressStates computed reactivity (recomputes on
  wholesale replacement, produces new references)
- Eviction behavior (retains below limit, evicts oldest above limit,
  preserves most recent, no-op when updating existing job)
- API event handler wiring via captured apiEventHandlers map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9249-fix-cap-nodeProgressStatesByJob-to-prevent-unbounded-memory-growth-3136d73d365081e49b36d8ade0d4dd6e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 17:51:14 -07:00
Christian Byrne
f5363e4028 fix: return undefined for muted node output resolution (#9302)
## Summary

Muted (NEVER mode) subgraph nodes throw "No inner node DTO found" during
prompt serialization because `resolveOutput()` falls through to subgraph
resolution for nodes whose inner DTOs were never registered.

## Changes

- **What**: Add early return in `ExecutableNodeDTO.resolveOutput()` for
`NEVER` mode nodes, matching the existing `BYPASS` mode guard. Add 5
tests covering muted, bypassed, and normal mode resolution.

## Review Focus

The fix is a single-line early return. The key insight is that
`graphToPrompt` in `executionUtil.ts` correctly skips `getInnerNodes()`
for muted/bypassed nodes, so their inner DTOs are never in the map — but
`resolveOutput()` was missing the corresponding guard for `NEVER` mode.

Fixes #8986

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9302-fix-return-undefined-for-muted-node-output-resolution-3156d73d3650811e9697c7281f11cf96)
by [Unito](https://www.unito.io)
2026-03-12 17:44:32 -07:00
Gregorius Bima Kharisma Wicaksana
4337b8d6c6 fix: prevent middle-click paste duplicating workflow on Linux (#8259)
## Summary

Adds `auxclick` event listener to prevent the browser's default
middle-click paste behavior on Linux systems.

**Problem:** On Linux, middle-clicking anywhere triggers a paste from
the PRIMARY clipboard. When middle-dragging to pan the canvas, this
causes the entire workflow to be duplicated as new nodes on mouse
release.

**Solution:** Add `auxclick` event listener with `preventDefault()` to
the graph canvas, blocking the paste while preserving pan functionality.

## Changes

- Add `auxclick` event listener in `bindEvents()` 
- Add corresponding `removeEventListener` in `unbindEvents()`

## Test Plan

- [ ] On Linux: Middle-drag to pan canvas - should pan without
duplicating nodes
- [ ] On Linux: Verify left/right click behaviors unchanged
- [ ] On Windows/macOS: Verify no regression (auxclick should have no
effect)

Fixes #4464

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8259-fix-prevent-middle-click-paste-duplicating-workflow-on-Linux-2f16d73d3650812b98f9cada699f5508)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 17:23:42 -07:00
Comfy Org PR Bot
113a2b5d92 1.42.4 (#9844)
Patch version increment to 1.42.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9844-1-42-4-3226d73d365081ffae69c3b809461de2)
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-12 17:21:20 -07:00
Christian Byrne
bb84dd202d fix: update workspace creation modal phrasing for credit pool clarity (#9811)
## Summary

Update workspace creation modal copy to clarify that creating a
workspace establishes a **new** credit pool, rather than sharing from
the owner's existing credits.

## Changes

- **What**: Changed i18n message from "Workspaces let members share a
single credits pool" to "Workspaces create a new credit pool that can be
shared among members"

## Review Focus

Copy change only — single i18n string update in
`src/locales/en/main.json`.

Fixes COM-16521

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9811-fix-update-workspace-creation-modal-phrasing-for-credit-pool-clarity-3216d73d36508186850ac5a8ad97461d)
by [Unito](https://www.unito.io)
2026-03-12 17:13:08 -07:00
Robin Huang
e5c8d26061 feat: set subscription tier as PostHog user property (#9764)
Sets subscription_tier as a PostHog person property via people.set after
user identification. Watches for async subscription status resolution so
the property updates even if the API responds after identify.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9764-feat-set-subscription-tier-as-PostHog-user-property-3216d73d3650812f8afcee0a830e434d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 14:47:14 -07:00
Simon Pinfold
37ff065061 fix: omit job_asset_name_filters when all job outputs selected (#9684)
## Summary

- When bulk exporting, `job_asset_name_filters` was always sent for
every job, restricting each job to only the assets the user clicked on.
For multi-output jobs, this meant the ZIP only contained 1 asset per job
instead of all outputs.
- Now compares selected asset count per job against `outputCount`
metadata and omits the filter for fully-selected jobs, so the backend
returns all assets.

## Test plan

- [x] Unit tests: all outputs selected → no filter sent
- [x] Unit tests: subset selected → filter sent
- [x] Unit tests: mixed selection → filter only for partial jobs
- [x] Unit tests: multiple fully-selected jobs → no filters
- [x] Typecheck, lint pass
- [ ] Manual: bulk export multi-output jobs in cloud env, verify ZIP
contains all outputs

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9684-fix-omit-job_asset_name_filters-when-all-job-outputs-selected-31f6d73d3650814482f8c59d05027d79)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-12 21:31:58 +00:00
John Haugeland
5fe31e63ec Cache execution id to node locator id mappings (#9244)
## Summary

nodeLocationProgressStates runs executionIdToNodeLocatorId for every
ancestor prefix of every node's display_node_id on every WebSocket
progress update. Each call traverses the subgraph hierarchy via
getNodeById. For nested subgraphs with many executing nodes, this
results in O(N × D²) graph lookups per progress tick, where N is
the number of executing nodes and D is the nesting depth.

Add a plain Map cache inside the execution store that memoizes
executionIdToNodeLocatorId results by execution ID string. The
cache persists across computed re-evaluations within a single
execution run, reducing subsequent progress updates to O(1)
lookups per execution ID.

Cache invalidation:
- Cleared at execution start (handleExecutionStart) to ensure
  fresh graph state for each new run
- Cleared at execution end (resetExecutionState) to prevent
  stale entries leaking across runs

The cache stores strings only (no graph node references), with
typical size of ~50-200 entries per run, so memory impact is
negligible.

- **What**: Caches a mapping of ids to ids, preventing an exponential
re-scan of subgraphs
- **Breaking**: Nothing
- **Dependencies**: None

You will need to set the feature flag
`ff:expose_executionId_to_node_locator_id_cache_counters` from the
underlying counter PR if you want to measure the impact.

```js
  localStorage.setItem(
    'ff:expose_executionId_to_node_locator_id_cache_counters',
    'true'
  )
```

## Review Focus

Before pulling this PR, pull
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9243 , set the
feature flag, and run a workflow with nested subgraphs, then look in
console to see a cache measurement.

Next, pull this PR and load the same workflow. Note the massive
reduction in visits.

## Screenshots

Login problems due to cross-opener policy are preventing me from taking
screenshots from a local dev build at this time

## Thread

There isn't one.  I don't have access to AmpCode or Unito.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9244-Cache-execution-id-to-node-locator-id-mappings-3136d73d365081e680eae3c891480ee7)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 14:13:27 -07:00
Christian Byrne
7206dea2d6 chore: add Sentry breadcrumbs to subgraph proxy widget operations (#8996)
## Summary

Add Sentry breadcrumbs to subgraph proxy widget operations for better
observability of widget state changes.

## Changes

- **What**: Add `Sentry.addBreadcrumb()` calls with category
`'subgraph'` to `promoteWidget`, `demoteWidget`, and `pruneDisconnected`
in `proxyWidgetUtils.ts`

## Review Focus

Breadcrumbs are info-level and don't affect control flow. They log
widget name/node ID for promote/demote and removed count for prune.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8996-chore-add-Sentry-breadcrumbs-to-subgraph-proxy-widget-operations-30d6d73d365081a5abbccabd39fc7129)
by [Unito](https://www.unito.io)
2026-03-12 13:51:29 -07:00
Rizumu Ayaka
8f07468fdd fix: dropdown widget fetching output files (#6734)
related
https://github.com/Comfy-Org/ComfyUI_frontend/issues/5827#issuecomment-3539629932

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6734-fix-dropdown-widget-fetching-output-files-2af6d73d365081988340f74694b7cee7)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 13:43:33 -07:00
Comfy Org PR Bot
8b9eb5f60d docs: Add TROUBLESHOOTING.md guide for common development issues (#7738)
## Summary

Adds a comprehensive `TROUBLESHOOTING.md` guide to help developers
resolve common development issues.

## Motivation

a developer reported issues where `pnpm dev` would get stuck on 'nx
serve'. This highlighted the need for centralized troubleshooting
documentation to help developers quickly resolve common issues without
having to wait for help.

## Changes

- Created `TROUBLESHOOTING.md` with FAQ-style documentation
- Added Mermaid flowchart for quick issue diagnosis
- Documented solutions for common problems:
  - Development server issues (nx serve hanging)
  - Build and TypeScript errors
  - Dependency and package management problems
  - Testing issues
  - Git and branch conflicts

## Structure

The guide includes:
- Quick diagnostic flowchart (Mermaid)
- Frequently Asked Questions with:
  - Clear symptoms
  - Step-by-step solutions
  - Explanations of why issues occur
- Links to community support resources
- Contribution guidelines

## Test Plan

- [x] File created and committed
- [x] Mermaid flowchart renders correctly
- [x] All commands are accurate and tested
- [x] Links to Discord and GitHub are valid

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7738-docs-Add-TROUBLESHOOTING-md-guide-for-common-development-issues-2d26d73d365081eda291e619b3067bc6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 13:42:37 -07:00
Rizumu Ayaka
8c93567019 perf: detect pointer drag in useTransformSettling for pan optimization (#9649)
## Summary

When many nodes are rendered in the transform container, both zoom and
pan can cause FPS drops because the browser re-rasterizes all visible
content at the new transform. `will-change: transform` tells the browser
to keep the layer as a GPU texture and skip re-rasterization during
active interaction, restoring visual quality only after settling.

- Add pointer drag detection so `will-change: transform` covers pan in
addition to zoom. Without this, dragging with 256+ nodes causes jank as
the browser re-rasterizes the entire layer on every frame of the pan.
- Fix settleDelay from 16ms to 256ms. At 16ms the debounce fires between
consecutive wheel events (~50ms apart on a physical mouse), causing
`will-change` to toggle on/off rapidly. Each toggle forces the browser
to promote/demote the compositor layer, which is more expensive than not
having the optimization at all.
- Replace scoped CSS with Tailwind `will-change-transform`.
- Remove per-node `will-change: transform` on `.lg-node`. Promoting each
node to its own compositor layer (256 nodes = 256 GPU textures)
increases memory pressure and compositing overhead, making performance
worse than a single promoted container.
- Previously, the virtual DOM of Nodes was updated during zooming and
dragging, but now this update is avoided through some techniques.
- Using the 3D versions of scale and translate can provide a smoother
experience when dealing with a large number of nodes.

## Test plan
- [x] Unit tests updated and passing
- [x] Manual: verify during both zoom and pan
- [x] Manual: compare pan FPS with 256 nodes before/after

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9649-perf-detect-pointer-drag-in-useTransformSettling-for-pan-optimization-31e6d73d3650818bb2c3ccd01a465140)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-12 13:30:18 -07:00
Johnpaul
70f61e3379 refactor: deduplicate preset logic per review feedback
- Reuse serializeKeybindingImpl via KeybindingImpl wrapper instead of
  reimplementing serialization for raw bindings
- Extract switchToDefaultPreset() to replace 3 duplicated reset patterns
- Extract promptAndSaveNewPreset() shared between presetService and
  KeybindingPanel
2026-03-11 19:21:02 +01:00
Johnpaul
52a56b3a88 feat: Increase the width of the keybinding preset select trigger. 2026-03-11 03:26:30 +01:00
Johnpaul
312c5e47a6 refactor: Adjust styling and reformat component attributes in KeybindingPresetToolbar.vue 2026-03-11 03:24:18 +01:00
Johnpaul
a40d274bfd fix: replace removed SelectSeparator with hr element 2026-03-11 03:22:06 +01:00
Johnpaul Chiwetelu
832ba4bec5 Merge branch 'main' into import/export-keybindings 2026-03-11 03:09:49 +01:00
Johnpaul
a8a7d566d8 feat: move preset toolbar to header right area 2026-03-11 03:04:42 +01:00
Johnpaul
7392551e70 Merge remote-tracking branch 'origin/main' into import/export-keybindings 2026-03-10 04:58:06 +01:00
Johnpaul
4602f31207 fix: reset keybindings when preset load fails on init 2026-03-10 04:53:13 +01:00
Johnpaul
42f78c9f36 feat: add buttonSize and buttonClass props to DropdownMenu 2026-03-10 03:14:18 +01:00
Johnpaul
0fd9989e3c feat: replace confirm dialogs with custom small layout dialogs
Use showSmallLayoutDialog pattern (like missing models dialog) for
unsaved changes and delete preset dialogs with explicit action buttons.
2026-03-10 03:13:44 +01:00
Johnpaul
2d9d699f33 fix: show save-as for clean custom presets and update error message
- Show "Save as new preset" for custom presets even without modifications
- Update invalidPresetName message to list all validation constraints
2026-03-10 01:55:37 +01:00
Johnpaul
35dbc244e9 fix: localize errors, persist fallback, and validate preset names
- Localize loadPreset and deletePreset error messages via i18n
- Persist 'default' setting when preset load fails in initPresets
- Reject names ending with .json extension in presetFilePath
- Add overwrite confirmation in switchPreset save-as flow
2026-03-10 01:21:46 +01:00
Johnpaul
b0171dd6c5 fix: address review findings for keybinding presets
- Delay setting currentPresetName until after successful preset load
- Prompt save-as when switching from modified default preset
- Trim and validate preset name before duplicate check in saveAsNewPreset
- Reject reserved name "default" in presetFilePath
- Convert onPresetsChanged callback prop to emit in KeybindingPresetToolbar
- Add tests for savePreset failure and "default" name rejection
2026-03-10 00:55:09 +01:00
Johnpaul
4b0a2668d6 fix: harden keybinding presets with validation and safety checks
- Fix watcher re-trigger on cancelled preset switch
- Add overwrite confirmation when saving as existing preset name
- Include targetElementId in dirty-check serialization
- Override embedded preset name with filename on load
- Check deleteUserData response status
- Sanitize preset names against path traversal
- Tighten zod schema with trim + min(1) on preset name
- Improve invalidPresetFile error message
2026-03-10 00:28:08 +01:00
GitHub Action
fe9b1a0964 [automated] Apply ESLint and Oxfmt fixes 2026-03-09 22:52:38 +00:00
Johnpaul
b2dd0d8ad3 fix: handle malformed JSON in preset import with localized error 2026-03-09 23:48:35 +01:00
Johnpaul
da144c709d fix: move initPresets to onMounted and prevent saving default preset
Address CodeRabbit review findings:
- Move fire-and-forget initPresets() call into onMounted lifecycle hook
- Skip savePreset when switching away from default preset to avoid
  writing keybindings/default.json
2026-03-09 23:38:46 +01:00
Johnpaul
83701d2bbd Merge remote-tracking branch 'origin/main' into import/export-keybindings 2026-03-09 23:28:36 +01:00
Johnpaul
5a4a33aee0 feat: add keybinding preset UI with toolbar, menu, and header teleport
Add KeybindingPresetToolbar with preset selector dropdown, save changes
button, and import option. Integrate into KeybindingPanel with teleported
search box and more-options menu in the settings dialog header row.
2026-03-09 23:23:45 +01:00
Johnpaul
bdbcba8f5c feat: add keybinding preset CRUD service with tests
Composable service for listing, loading, saving, deleting, importing,
and exporting keybinding presets via the userdata API. Includes
switchPreset with unsaved-changes confirmation. 11 unit tests.
2026-03-09 23:22:42 +01:00
Johnpaul
ecf0411799 feat: add keybinding preset schema, setting, and store tracking
Add KeybindingPreset Zod schema and type, a hidden setting for the
current preset name, and preset-tracking state in keybindingStore
(currentPresetName, savedPresetData, isCurrentPresetModified).
2026-03-09 23:21:59 +01:00
150 changed files with 6658 additions and 882 deletions

9
.claude/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
]
}
}

View File

@@ -0,0 +1,361 @@
---
name: ticket-intake
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
---
# Ticket Intake
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
## Supported Sources
| Source | URL Pattern | Provider File |
| ------ | --------------------------------------------------- | --------------------- |
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
## Quick Start
When given a ticket URL:
1. **Detect source type** from URL pattern
2. **Load provider-specific logic** from `providers/` directory
3. Fetch ticket content via appropriate API
4. Extract and normalize properties to common schema
5. **Register ticket in pipeline API** ← MANDATORY
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
7. **Run verification script** to confirm API registration
8. Output summary and handoff to `research-orchestrator`
## Configuration
Uses the **production API** by default. No configuration needed for read operations.
**Defaults (no setup required):**
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
- Read-only endpoints at `/public/*` require no authentication
**For write operations** (transitions, creating tickets), set:
```bash
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
```
**Optional (for local working artifacts):**
```bash
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
```
## Mandatory API Calls (Execute ALL Three)
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
### Call 1: Create Ticket
```bash
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
API_KEY="${PIPELINE_API_KEY}"
curl -s -X POST "${API_URL}/v1/tickets" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"notion_page_id": "NOTION_PAGE_UUID_HERE",
"title": "TICKET_TITLE_HERE",
"source": "notion",
"metadata": {
"description": "DESCRIPTION_HERE",
"priority": "High",
"labels": [],
"acceptanceCriteria": []
}
}'
```
Save the returned `id` — you need it for the next two calls.
### Call 2: Transition to RESEARCH
```bash
TICKET_ID="id-from-step-1"
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"to_state": "RESEARCH",
"reason": "Intake complete, starting research"
}'
```
### Call 3: Queue Source Update
```bash
curl -s -X POST "${API_URL}/v1/sync/queue" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"ticket_id": "TICKET_ID_HERE",
"action": "update_status",
"payload": { "status": "In Progress" },
"priority": "normal"
}'
```
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
### TypeScript Equivalent (if using pipeline client)
```typescript
import { PipelineClient } from '@pipeline/client'
const client = new PipelineClient({
apiUrl:
process.env.PIPELINE_API_URL ||
'https://api-gateway-856475788601.us-central1.run.app',
agentId: process.env.AGENT_ID!
})
const ticket = await client.createTicket({
notion_page_id: pageId,
title: ticketTitle,
source: 'notion',
metadata: { description, priority, labels, acceptanceCriteria }
})
await client.transitionState(
ticket.id,
'RESEARCH',
'Intake complete, starting research'
)
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
```
## Workflow
### Step 1: Detect Source Type
Parse the URL to determine source:
```javascript
if (url.includes('notion.so')) {
source = 'notion'
// Load providers/notion.md
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
source = 'github'
// Load providers/github.md
} else {
// Error: Unsupported URL format
}
```
### Step 2: Load Provider and Fetch Data
Read the appropriate provider file for source-specific instructions:
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
Follow the provider's instructions for:
- Fetching content
- Extracting properties
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
### Step 3: Normalize to Common Schema
All providers must extract normalized ticket data following `schema.md`:
```json
{
"id": "abc12345",
"url": "https://...",
"source": "notion | github",
"title": "Ticket title",
"description": "Full description",
"status": "Not Started",
"assignee": "username",
"priority": "High",
"area": "UI",
"labels": ["bug", "frontend"],
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
"fetchedAt": "2024-01-15T10:30:00Z"
}
```
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
1. `createTicket()` → save the returned ticket ID
2. `transitionState(id, 'RESEARCH')` → confirm state changed
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
### Step 5: Run Verification Script
After making the API calls, run the verification script to confirm everything worked:
```bash
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
```
**If the script is not available locally**, verify manually via the public API:
```bash
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
```
Expected output:
```json
{
"id": "...",
"state": "RESEARCH",
"title": "...",
"notion_page_id": "..."
}
```
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
### Step 6: Output Summary and Handoff
Print a clear summary:
```markdown
## Ticket Intake Complete
**Source:** Notion | GitHub
**Title:** [Ticket title]
**ID:** abc12345
**Status:** In Progress (queued)
**Priority:** High
**Area:** UI
### Description
[Brief description or first 200 chars]
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Links
- **Ticket:** [Original URL]
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
### Pipeline
- **API Ticket ID:** abc12345
- **State:** RESEARCH
- **Verified:** ✅ (via verify-intake.sh or public API)
```
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
## Error Handling
### Unsupported URL
```
❌ Unsupported ticket URL format.
Supported formats:
- Notion: https://notion.so/... or https://www.notion.so/...
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
Received: [provided URL]
```
### Provider-Specific Errors
See individual provider files for source-specific error handling:
- `providers/notion.md` - Authentication, page not found
- `providers/github.md` - Auth, rate limits, issue not found
### Missing Properties
Continue with available data and note what's missing:
```
⚠️ Some properties unavailable:
- Priority: not found (using default: Medium)
- Area: not found
Proceeding with available data...
```
### API Call Failures
```
❌ Pipeline API call failed: {method} {endpoint}
Status: {status}
Error: {message}
Retrying once...
❌ Retry also failed. INTAKE IS INCOMPLETE.
The ticket was NOT registered in the pipeline.
Downstream skills will not work until this is fixed.
```
## Notes
- This skill focuses ONLY on intake — it does not do research
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
- Pipeline state is tracked via the API, not local files
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
- The `source` field in the ticket determines which research strategies to use
## API Client Reference
### Available Methods
| Method | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
| `getTicket(id)` | Retrieve a ticket by ID |
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
### Error Handling
```typescript
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
try {
await client.createTicket({ ... });
} catch (error) {
if (error instanceof PipelineAPIError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
```

View File

@@ -0,0 +1,194 @@
# GitHub Provider - Ticket Intake
Provider-specific logic for ingesting tickets from GitHub Issues.
## URL Pattern
```
https://github.com/{owner}/{repo}/issues/{number}
https://www.github.com/{owner}/{repo}/issues/{number}
```
Extract: `owner`, `repo`, `issue_number` from URL.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Access to the repository
## Fetch Issue Content
Use `gh` CLI to fetch issue details:
```bash
# Get issue details in JSON
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
# Get comments separately if needed
gh issue view {number} --repo {owner}/{repo} --comments
```
## Extract Ticket Data
Map GitHub issue fields to normalized ticket data (stored via API):
| GitHub Field | ticket.json Field | Notes |
| ------------ | ----------------- | -------------------------- |
| title | title | Direct mapping |
| body | description | Issue body/description |
| state | status | Map: open → "Not Started" |
| labels | labels | Array of label names |
| assignees | assignee | First assignee login |
| author | author | Issue author login |
| milestone | milestone | Milestone title if present |
| comments | comments | Array of comment objects |
| linkedPRs | linkedPRs | PRs linked to this issue |
### Priority Mapping
Infer priority from labels:
- `priority:critical`, `P0` → "Critical"
- `priority:high`, `P1` → "High"
- `priority:medium`, `P2` → "Medium"
- `priority:low`, `P3` → "Low"
- No priority label → "Medium" (default)
### Area Mapping
Infer area from labels:
- `area:ui`, `frontend`, `component:*` → "UI"
- `area:api`, `backend` → "API"
- `area:docs`, `documentation` → "Docs"
- `bug`, `fix` → "Bug"
- `enhancement`, `feature` → "Feature"
## Update Source
**For GitHub issues, update is optional but recommended.**
Add a comment to indicate work has started:
```bash
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
```
Optionally assign to self:
```bash
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
```
Log any updates via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
githubWrites: [
...(ticket.metadata?.githubWrites || []),
{
action: 'comment',
issueNumber: 123,
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
## GitHub-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "github",
"githubOwner": "Comfy-Org",
"githubRepo": "ComfyUI_frontend",
"githubIssueNumber": 123,
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
"labels": ["bug", "area:ui", "priority:high"],
"linkedPRs": [456, 789],
"dosuComment": "..." // Extracted Dosu bot analysis if present
}
```
## Dosu Bot Detection
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
```bash
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
```
Look for comments from:
- `dosu[bot]`
- `dosu-bot`
Extract Dosu analysis which typically includes:
- Root cause analysis
- Suggested files to modify
- Related issues/PRs
- Potential solutions
Store in ticket data via API:
```json
{
"dosuComment": {
"found": true,
"analysis": "...",
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
"relatedIssues": [100, 101]
}
}
```
## Extract Linked Issues/PRs
Parse issue body and comments for references:
- `#123` → Issue or PR reference
- `fixes #123`, `closes #123` → Linked issue
- `https://github.com/.../issues/123` → Full URL reference
Store in ticket data via API for research phase:
```json
{
"referencedIssues": [100, 101, 102],
"referencedPRs": [200, 201]
}
```
## Error Handling
### Authentication Error
```
⚠️ GitHub CLI not authenticated.
Run: gh auth login
```
### Issue Not Found
```
❌ GitHub issue not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this repository
- Run: gh auth status
```
### Rate Limiting
```
⚠️ GitHub API rate limited.
Wait a few minutes and try again.
Check status: gh api rate_limit
```

View File

@@ -0,0 +1,202 @@
# Notion Provider - Ticket Intake
Provider-specific logic for ingesting tickets from Notion.
## URL Pattern
```
https://www.notion.so/workspace/Page-Title-abc123def456...
https://notion.so/Page-Title-abc123def456...
https://www.notion.so/abc123def456...
```
Page ID is the 32-character hex string (with or without hyphens).
## Prerequisites
- Notion MCP connected and authenticated
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
- Authenticate via `/mcp` command if prompted
## Fetch Ticket Content
Use `Notion:notion-fetch` with the page URL or ID:
```
Fetch the full page content including all properties
```
## Extract Ticket Data
Extract these properties (names may vary):
| Property | Expected Name | Type |
| ------------- | ------------------------- | ------------ |
| Title | Name / Title | Title |
| Status | Status | Select |
| Assignee | Assignee / Assigned To | Person |
| Description | - | Page content |
| Slack Link | Slack Link / Slack Thread | URL |
| GitHub PR | GitHub PR / PR Link | URL |
| Priority | Priority | Select |
| Area | Area / Category | Select |
| Related Tasks | Related Tasks | Relation |
**If properties are missing**: Note what's unavailable and continue with available data.
## Update Source (REQUIRED)
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
Use `Notion:notion-update-page` to update the ticket:
1. **Status**: Set to "In Progress" (only valid from "Not Started")
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
```json
{
"page_id": "{page_id_from_ticket}",
"command": "update_properties",
"properties": {
"Status": "In Progress",
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
}
}
```
**After the update succeeds**, log the write via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
notionWrites: [
...(ticket.metadata?.notionWrites || []),
{
field: 'Status',
value: 'In Progress',
previousValue: 'Not Started',
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
If update fails, log with `success: false` and continue.
## Notion-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "notion",
"notionPageId": "abc123def456...",
"slackLink": "https://slack.com/...",
"relatedTasks": ["page-id-1", "page-id-2"]
}
```
## Slack Thread Handling
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
### Slack URL Conversion
Notion stores Slack links in `slackMessage://` format:
```
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
```
Convert to browser-clickable format:
```
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
```
**Example:**
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
(Remove the dot from the last timestamp and prefix with `p`)
### Fetching Thread Content
Load the `slackdump` skill and use the **export-thread** workflow:
```bash
# Export thread by URL
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
# Or by colon notation (channel_id:thread_ts)
slackdump dump CHANNEL_ID:THREAD_TS
```
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
## Database Reference: Comfy Tasks
The "Comfy Tasks" database has these properties (verify via `notion-search`):
- **Status values**: Not Started, In Progress, In Review, Done
- **Team assignment**: "Frontend Team" for unassigned tickets
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
### Pipeline Owner Details
When assigning tickets, use these identifiers:
| Platform | Identifier |
| --------------- | -------------------------------------- |
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
| Notion Name | Christian Byrne |
| Notion Email | cbyrne@comfy.org |
| Slack User ID | U087MJCDHHC |
| GitHub Username | christian-byrne |
**To update Assignee**, use the Notion User ID (not name):
```
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
```
### Finding Active Tickets
To list your active tickets:
```
Use Notion:notion-search for "Comfy Tasks"
Filter by Assignee = current user OR Team = "Frontend Team"
```
## Error Handling
### Authentication Error
```
⚠️ Notion authentication required.
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
Then authenticate via /mcp command.
```
### Page Not Found
```
❌ Notion page not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this page
- Try re-authenticating via /mcp
```

View File

@@ -0,0 +1,81 @@
# Ticket Schema
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
## Ticket Data Schema
```json
{
// Required fields (all sources)
"id": "string", // Unique identifier (short form)
"url": "string", // Original URL
"source": "notion | github", // Source type
"title": "string", // Ticket title
"description": "string", // Full description/body
"fetchedAt": "ISO8601", // When ticket was fetched
// Common optional fields
"status": "string", // Current status
"assignee": "string", // Assigned user
"priority": "string", // Priority level
"area": "string", // Category/area
"labels": ["string"], // Tags/labels
"acceptanceCriteria": ["string"] // List of AC items
// Source-specific fields (see providers)
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
}
```
## Ticket State Schema (via API)
State is managed via the Pipeline API using `client.transitionState()`:
```json
{
"ticketId": "string",
"state": "intake | research | planning | implementation | pr_created | done | failed",
"stateChangedAt": "ISO8601",
// Timestamps tracked by API
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
## Priority Normalization
All sources should normalize to these values:
| Normalized | Description |
| ---------- | ------------------------- |
| Critical | Production down, security |
| High | Blocking work, urgent |
| Medium | Normal priority (default) |
| Low | Nice to have, backlog |
## Status Normalization
Pipeline tracks these statuses internally:
| Status | Description |
| -------------- | ---------------------------- |
| research | Gathering context |
| planning | Creating implementation plan |
| implementation | Writing code |
| review | Code review in progress |
| qa | Quality assurance |
| done | PR merged or completed |
## ID Generation
IDs are generated by the API when creating tickets. For reference:
- **Notion**: First 8 characters of page ID
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
Examples:
- Notion: `abc12345`
- GitHub: `gh-comfy-org-frontend-123`

View File

@@ -0,0 +1,143 @@
---
name: writing-storybook-stories
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
---
# Write Storybook Stories for ComfyUI_frontend
## Workflow
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
3. Read nearby stories before writing anything.
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
4. If a Figma link is provided, list the states you need to cover before writing stories.
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
7. Run Storybook and validation checks before handing off.
## Match Local Conventions
- Copy the closest neighboring story instead of forcing one universal template.
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
- Add decorators for realistic width or background context when the component needs it.
## Title Patterns
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
| Component area | Typical title pattern |
| ------------------------------------------------------- | ------------------------------------ |
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
| `src/components/ui/input/Input.vue` | `Components/Input` |
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
## Common Story Shapes
### Stateful input or `v-model`
```typescript
export const Default: Story = {
render: (args) => ({
components: { MyComponent },
setup() {
const { disabled, size } = toRefs(args)
const value = ref('Hello world')
return { value, disabled, size }
},
template:
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
})
}
```
### Slot-driven content
```typescript
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
argTypes: {
default: { control: 'text' }
},
args: {
default: 'Button'
}
}
export const SingleButton: Story = {
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">{{ args.default }}</Button>'
})
}
```
### Variants or edge cases grid
```typescript
export const AllVariants: Story = {
render: () => ({
components: { MyComponent },
template: `
<div class="grid gap-4 sm:grid-cols-2">
<MyComponent />
<MyComponent disabled />
<MyComponent loading />
<MyComponent invalid />
</div>
`
})
}
```
## Figma Mapping
- Extract the named states from the design first.
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
- Use pseudo-state parameters only if the addon is already configured in this repo.
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
## Component-Specific Notes
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
## Checklist
- [ ] Read the component source and any supporting types or composables
- [ ] Match the nearest local title pattern and story style
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
- [ ] Add focused stories for meaningful states
- [ ] Add `tags: ['autodocs']`
- [ ] Keep the story co-located with the component
- [ ] Run `pnpm storybook`
- [ ] Run `pnpm typecheck`
- [ ] Run `pnpm lint`
## Avoid
- Do not guess props, emits, slots, or exposed methods.
- Do not force one generic title convention across the repo.
- Do not mutate Storybook args directly for `v-model` components.
- Do not introduce `dark:` Tailwind variants in story wrappers.
- Do not create barrel files.
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.

View File

@@ -0,0 +1,4 @@
interface:
display_name: 'ComfyUI Storybook Stories'
short_description: 'Write Vue Storybook stories for ComfyUI'
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'

View File

@@ -19,7 +19,7 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'

View File

@@ -61,6 +61,22 @@ jobs:
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git push
- name: Fail for fork PRs with unfixed lint/format issues
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Linting/formatting issues found. Since this PR is from a fork, auto-fix cannot be applied automatically."
echo ""
echo "Please run these commands locally and push the changes:"
echo " pnpm lint:fix"
echo " pnpm stylelint:fix"
echo " pnpm format"
echo ""
echo "Or set up pre-commit hooks to automatically format on every commit:"
echo " pnpm prepare"
echo ""
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Final validation
run: |
pnpm lint
@@ -84,16 +100,3 @@ jobs:
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
})
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\npnpm prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\npnpm lint:fix\npnpm format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
})

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (see `.nvmrc`, currently v24) and pnpm
- Node.js (see `.nvmrc` for the required version) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
@@ -87,6 +87,10 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
> ⚠️ IMPORTANT:
> The dev server will NOT load JavaScript extensions from custom nodes. Only core extensions (built into the frontend) will be loaded. This is because the shim system that allows custom node JavaScript to import frontend modules only works in production builds. Python custom nodes still function normally. See [Extension Development Guide](docs/extensions/development.md) for details and workarounds. And See [Extension Overview](docs/extensions/README.md) for extensions overview.
## Troubleshooting
If you run into issues during development (e.g. `pnpm dev` hanging, TypeScript errors after pulling, lock file conflicts), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common fixes.
## Development Workflow
### Architecture Decision Records

368
TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,368 @@
# Troubleshooting Guide
This guide helps you resolve common issues when developing ComfyUI Frontend.
## Quick Diagnostic Flowchart
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[nx serve hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
B -->|Other| F[See FAQ below]
Q --> R{oxlint or ESLint?}
R -->|oxlint| S[Check .oxlintrc.json<br/>and run pnpm lint:fix]
R -->|ESLint| T[Check eslint.config.ts<br/>and run pnpm lint:fix]
S --> L
T --> L
C --> G{Tried quick fixes?}
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
D --> M[Run: pnpm build]
M --> N{Build succeeds?}
N -->|No| O[Check error messages<br/>in FAQ]
N -->|Yes| L
E --> H
F --> P[Search FAQ or<br/>ask in Discord]
```
## Frequently Asked Questions
### Development Server Issues
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
**Symptoms:**
- Command hangs on "nx serve"
- Dev server doesn't respond
- Terminal appears frozen
**Solutions (try in order):**
1. **First attempt - Reinstall dependencies:**
```bash
pnpm i
```
2. **Second attempt - Clean build cache:**
```bash
pnpm clean
```
3. **Last resort - Full node_modules reset:**
```bash
pnpm dlx rimraf node_modules && pnpm i
```
**Why this happens:**
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- NX cache corruption
---
#### Q: Port conflicts - "Address already in use"
**Symptoms:**
- Error: `EADDRINUSE` or "port already in use"
- Dev server fails to start
**Solutions:**
1. **Find and kill the process using the port:**
```bash
# On Linux/Mac
lsof -ti:5173 | xargs kill -9
# On Windows
netstat -ano | findstr :5173
taskkill /PID <PID> /F
```
2. **Use a different port** by adding a `port` option to the `server` block in `vite.config.mts`:
```ts
server: {
port: 3000,
// ...existing config
}
```
---
### Build and Type Issues
#### Q: TypeScript errors after pulling latest changes
**Symptoms:**
- Type errors in files you didn't modify
- "Cannot find module" errors
**Solutions:**
1. **Rebuild TypeScript references:**
```bash
pnpm build
```
2. **Clean and reinstall:**
```bash
pnpm clean && pnpm i
```
3. **Restart your IDE's TypeScript server**
- VS Code: `Cmd/Ctrl + Shift + P` → "TypeScript: Restart TS Server"
---
#### Q: "Workspace not found" or monorepo errors
**Symptoms:**
- pnpm can't find workspace packages
- Import errors between packages
**Solutions:**
1. **Verify you're in the project root:**
```bash
pwd # Should be in ComfyUI_frontend/
```
2. **Rebuild workspace:**
```bash
pnpm install
pnpm build
```
---
### Linting Issues (oxlint)
#### Q: `eslint-disable` comment isn't suppressing an oxlint rule
**Symptoms:**
- `// eslint-disable-next-line rule-name` has no effect
- Lint error persists despite the disable comment
**Solution:**
oxlint has its own disable syntax. Use `oxlint-disable` instead:
```ts
// oxlint-disable-next-line no-console
console.log('debug')
```
Check whether the rule is enforced by oxlint (in `.oxlintrc.json`) or ESLint (in `eslint.config.ts`) to pick the right disable comment.
---
#### Q: New lint errors after pulling/upgrading oxlint
**Symptoms:**
- Lint errors in files you didn't change
- Rules you haven't seen before (e.g. `no-immediate-mutation`, `prefer-optional-chain`)
**Solutions:**
1. **Run the auto-fixer first:**
```bash
pnpm lint:fix
```
2. **Review changes carefully** — some oxlint auto-fixes can produce incorrect code. Check the diff before committing.
3. **If a rule seems wrong**, check `.oxlintrc.json` to see if it should be disabled or configured differently.
**Why this happens:** oxlint version bumps often enable new rules by default.
---
#### Q: oxlint fails with TypeScript errors
**Symptoms:**
- `pnpm oxlint` or `pnpm lint` fails with type-related errors
- Errors mention type resolution or missing type information
**Solution:**
oxlint runs with `--type-aware` in this project, which requires valid TypeScript compilation. Fix the TS errors first:
```bash
pnpm typecheck # Identify TS errors
pnpm build # Or do a full build
pnpm lint # Then re-run lint
```
---
#### Q: Duplicate lint errors from both oxlint and ESLint
**Symptoms:**
- Same violation reported twice
- Conflicting auto-fix suggestions
**Solution:**
The project uses `eslint-plugin-oxlint` to automatically disable ESLint rules that oxlint already covers (see `eslint.config.ts`). If you see duplicates:
1. Ensure `.oxlintrc.json` is up to date after adding new oxlint rules
2. Run `pnpm lint` (which runs oxlint then ESLint in sequence) rather than running them individually
---
### Dependency and Package Issues
#### Q: "Package not found" after adding a dependency
**Symptoms:**
- Module not found after `pnpm add`
- Import errors for newly installed packages
**Solutions:**
1. **Ensure you installed in the correct workspace** (see `pnpm-workspace.yaml` for available workspaces):
```bash
# Example: install in a specific workspace
pnpm --filter <workspace-name> add <package>
```
2. **Clear pnpm cache:**
```bash
pnpm store prune
pnpm install
```
---
#### Q: Lock file conflicts after merge/rebase
**Symptoms:**
- Git conflicts in `pnpm-lock.yaml`
- Dependency resolution errors
**Solutions:**
1. **Regenerate lock file:**
```bash
rm pnpm-lock.yaml
pnpm install
```
2. **Or accept upstream lock file:**
```bash
git checkout --theirs pnpm-lock.yaml
pnpm install
```
---
### Testing Issues
#### Q: Tests fail locally but pass in CI
**Symptoms:**
- Flaky tests
- Different results between local and CI
**Solutions:**
1. **Run tests in CI mode:**
```bash
CI=true pnpm test:unit
```
2. **Clear test cache:**
```bash
pnpm test:unit --no-cache
```
3. **Check Node version matches CI** (see `.nvmrc` for the required version):
```bash
node --version
nvm use # If using nvm — reads .nvmrc automatically
```
---
### Git and Branch Issues
#### Q: Changes from another branch appearing in my branch
**Symptoms:**
- Uncommitted changes not related to your work
- Dirty working directory
**Solutions:**
1. **Stash and reinstall:**
```bash
git stash
pnpm install
```
2. **Check for untracked files:**
```bash
git status
git clean -fd # Careful: removes untracked files!
```
---
## Still Having Issues?
1. **Search existing issues:** [GitHub Issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)
2. **Ask the community:** [Discord](https://discord.com/invite/comfyorg) (navigate to the `#dev-frontend` channel)
3. **Create a new issue:** Include:
- Your OS and Node version (`node --version`)
- Steps to reproduce
- Full error message
- What you've already tried
## Contributing to This Guide
Found a solution to a common problem? Please:
1. Open a PR to add it to this guide
2. Follow the FAQ format above
3. Include the symptoms, solutions, and why it happens
---
**Last Updated:** 2026-03-10

View File

@@ -27,7 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
Ensure you have the Node.js version specified in `.nvmrc` installed.
Then, set up the Chromium test driver:
```bash

View File

@@ -328,9 +328,11 @@ test.describe('Settings', () => {
})
await newBlankWorkflowRow.click()
// Click edit button
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
await editKeybindingButton.click()
// Click add keybinding button (New Blank Workflow has no default keybinding)
const addKeybindingButton = newBlankWorkflowRow.locator(
'.icon-\\[lucide--plus\\]'
)
await addKeybindingButton.click()
// Set new keybinding
const input = comfyPage.page.getByPlaceholder('Enter your keybind')

View File

@@ -0,0 +1,123 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
* from the canvas in the browser.
*/
async function getSelectionCounts(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
let selectedNodeCount = 0
let selectedGroupCount = 0
for (const item of canvas.selectedItems) {
if ('inputs' in item || 'outputs' in item) selectedNodeCount++
else selectedGroupCount++
}
return {
selectedNodeCount,
selectedGroupCount,
selectedItemCount: canvas.selectedItems.size
}
})
}
test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Setting enabled: clicking outer group selects nested group and inner node', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
// Outer Group + Inner Group + 1 node = 3 items
expect(counts.selectedItemCount).toBe(3)
expect(counts.selectedGroupCount).toBe(2)
expect(counts.selectedNodeCount).toBe(1)
})
test('Setting disabled: clicking outer group selects only the group', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(1)
expect(counts.selectedGroupCount).toBe(1)
expect(counts.selectedNodeCount).toBe(0)
})
test('Deselecting outer group deselects all children', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
let counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(3)
// Deselect all via page.evaluate to avoid UI overlay interception
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

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

After

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

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

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

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 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: 137 KiB

After

Width:  |  Height:  |  Size: 137 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: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,102 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const

1
global.d.ts vendored
View File

@@ -1,4 +1,5 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string

View File

@@ -47,7 +47,60 @@
border: 0;
}
</style>
<link rel="stylesheet" href="splash.css" />
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="manifest" href="manifest.json" />
</head>

View File

@@ -11,7 +11,7 @@ const config: KnipConfig = {
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.42.3",
"version": "1.42.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1420,15 +1420,6 @@ audio.comfy-audio.empty-audio-widget {
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

View File

@@ -12,6 +12,7 @@ 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'
import type { ButtonVariants } from '../ui/button/button.variants'
defineOptions({
inheritAttrs: false
@@ -23,6 +24,8 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
to?: string | HTMLElement
itemClass?: string
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
}>()
const itemClass = computed(() =>
@@ -44,7 +47,7 @@ const contentClass = computed(() =>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>

View File

@@ -59,6 +59,8 @@ import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
@@ -77,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
@@ -104,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
@@ -119,7 +123,9 @@ function isOutdated(column: ColumnDef): boolean {
}
function getDisplayValue(column: ColumnDef) {
const value = systemInfo.value[column.field]
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}

View File

@@ -30,31 +30,33 @@
</div>
<div class="flex shrink-0 items-center gap-2">
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
<span
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-else-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-else-if="model.isDownloadable">
<span
v-if="fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<Button
v-else
variant="textonly"

View File

@@ -1,105 +1,283 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchInput
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
class="max-w-96"
size="lg"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</Teleport>
<DataTable
v-model:selection="selectedCommandData"
:value="commandsData"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
striped-rows
:pt="{
header: 'px-0'
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row">
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data)"
>
<i class="pi pi-pencil" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
@click="resetKeybinding(slotProps.data)"
>
<i class="pi pi-replay" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="!slotProps.data.keybinding"
@click="removeKeybinding(slotProps.data)"
>
<i class="pi pi-trash" />
</Button>
</div>
</template>
</Column>
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1.5 truncate"
:title="slotProps.data.id"
<Teleport defer to="#keybinding-panel-actions">
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
button-class="size-10"
/>
</div>
</Teleport>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
:value="commandsData"
data-key="id"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
context-menu
striped-rows
:pt="{
header: 'px-0'
}"
@row-click="handleRowClick($event)"
@row-dblclick="handleRowDblClick($event.data)"
@row-contextmenu="handleRowContextMenu($event)"
>
<i
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
:key-combo="slotProps.data.keybinding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybindings.length >= 2"
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform"
:class="
expandedCommandIds.has(slotProps.data.id) && 'rotate-90'
"
/>
<i
v-if="
slotProps.data.keybindings.some(
(b: KeybindingImpl) => b.combo.isBrowserReserved
)
"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybindings"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="
editKeybinding(
slotProps.data,
slotProps.data.keybindings[0]
)
"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.addNewKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.addNewKeybinding')"
@click="addKeybinding(slotProps.data)"
>
<i class="icon-[lucide--plus]" />
</Button>
<Button
v-tooltip="$t('g.reset')"
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
@click="resetKeybinding(slotProps.data)"
>
<i class="icon-[lucide--rotate-ccw]" />
</Button>
<Button
v-tooltip="$t('g.delete')"
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="slotProps.data.keybindings.length === 0"
@click="handleRemoveKeybindingFromMenu(slotProps.data)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
<span class="text-muted-foreground">{{
slotProps.data.label
}}</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</div>
<div class="flex flex-row">
<Button
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data, binding)"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.removeKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.removeKeybinding')"
@click="removeSingleKeybinding(slotProps.data, idx)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</div>
</div>
</template>
</DataTable>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
"
/>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
</DataTable>
@select="ctxChangeKeybinding"
>
<i class="icon-[lucide--pencil] size-4" />
{{ $t('g.changeKeybinding') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered"
@select="ctxAddKeybinding"
>
<i class="icon-[lucide--plus] size-4" />
{{ $t('g.addNewKeybinding') }}
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget ||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
"
@select="ctxResetToDefault"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
{{ $t('g.resetToDefault') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
"
@select="ctxRemoveKeybinding"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ $t('g.removeKeybinding') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
@@ -107,29 +285,44 @@
variant="destructive-textonly"
@click="resetAllKeybindings"
>
<i class="pi pi-replay" />
<i class="icon-[lucide--rotate-ccw]" />
{{ $t('g.resetAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({
@@ -138,12 +331,94 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const presetService = useKeybindingPresetService()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const presetNames = ref<string[]>([])
async function refreshPresetList() {
presetNames.value = (await presetService.listPresets()) ?? []
}
async function initPresets() {
await refreshPresetList()
const currentName = settingStore.get('Comfy.Keybinding.CurrentPreset')
if (currentName !== 'default') {
const preset = await presetService.loadPreset(currentName)
if (preset) {
keybindingStore.savedPresetData = preset
keybindingStore.currentPresetName = currentName
} else {
await presetService.switchToDefaultPreset()
}
}
}
onMounted(() => initPresets())
// "..." menu entries (teleported to header)
async function saveAsNewPreset() {
await presetService.promptAndSaveNewPreset()
refreshPresetList()
}
async function handleDeletePreset() {
await presetService.deletePreset(keybindingStore.currentPresetName)
refreshPresetList()
}
async function handleImportPreset() {
await presetService.importPreset()
refreshPresetList()
}
const showSaveAsNew = computed(
() =>
keybindingStore.currentPresetName !== 'default' ||
keybindingStore.isCurrentPresetModified
)
const menuEntries = computed<MenuItem[]>(() => [
...(showSaveAsNew.value
? [
{
label: t('g.keybindingPresets.saveAsNewPreset'),
icon: 'icon-[lucide--save]',
command: saveAsNewPreset
}
]
: []),
{
label: t('g.keybindingPresets.resetToDefault'),
icon: 'icon-[lucide--rotate-cw]',
command: () =>
presetService.switchPreset('default').then(() => refreshPresetList())
},
{
label: t('g.keybindingPresets.deletePreset'),
icon: 'icon-[lucide--trash-2]',
disabled: keybindingStore.currentPresetName === 'default',
command: handleDeletePreset
},
{
label: t('g.keybindingPresets.importPreset'),
icon: 'icon-[lucide--file-input]',
command: handleImportPreset
},
{
label: t('g.keybindingPresets.exportPreset'),
icon: 'icon-[lucide--file-output]',
command: () => presetService.exportPreset()
}
])
// Keybinding table logic
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
keybindings: KeybindingImpl[]
label: string
source?: string
}
@@ -155,31 +430,166 @@ const commandsData = computed<ICommandData[]>(() => {
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
source: command.source
}))
})
const expandedCommandIds = ref<Set<string>>(new Set())
const expandedRows = computed({
get() {
const result: Record<string, boolean> = {}
for (const id of expandedCommandIds.value) {
result[id] = true
}
return result
},
set(value: Record<string, boolean>) {
expandedCommandIds.value = new Set(Object.keys(value))
}
})
function toggleExpanded(commandId: string) {
if (expandedCommandIds.value.has(commandId)) {
expandedCommandIds.value.delete(commandId)
} else {
expandedCommandIds.value.add(commandId)
}
}
watch(filters, () => expandedCommandIds.value.clear(), { deep: true })
const selectedCommandData = ref<ICommandData | null>(null)
const editKeybindingDialog = useEditKeybindingDialog()
function editKeybinding(commandData: ICommandData) {
const contextMenuTarget = ref<ICommandData | null>(null)
function editKeybinding(commandData: ICommandData, binding: KeybindingImpl) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: commandData.keybinding?.combo ?? null
currentCombo: binding.combo,
mode: 'edit',
existingBinding: binding
})
}
async function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
function addKeybinding(commandData: ICommandData) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: null,
mode: 'add'
})
}
function handleRowClick(event: { originalEvent: Event; data: ICommandData }) {
const target = event.originalEvent.target as HTMLElement
if (target.closest('.actions')) return
const commandData = event.data
if (
commandData.keybindings.length >= 2 ||
expandedCommandIds.value.has(commandData.id)
) {
toggleExpanded(commandData.id)
}
}
function handleRowDblClick(commandData: ICommandData) {
if (commandData.keybindings.length === 0) {
addKeybinding(commandData)
} else if (commandData.keybindings.length === 1) {
editKeybinding(commandData, commandData.keybindings[0])
}
}
function handleRowContextMenu(event: {
originalEvent: Event
data: ICommandData
}) {
contextMenuTarget.value = event.data
}
function clearContextMenuTarget() {
contextMenuTarget.value = null
}
async function removeSingleKeybinding(
commandData: ICommandData,
index: number
) {
const binding = commandData.keybindings[index]
if (binding) {
keybindingStore.unsetKeybinding(binding)
if (commandData.keybindings.length <= 2) {
expandedCommandIds.value.delete(commandData.id)
}
await keybindingService.persistUserKeybindings()
}
}
function handleRemoveAllKeybindings(commandData: ICommandData) {
const dialog = showConfirmDialog({
headerProps: { title: t('g.removeAllKeybindingsTitle') },
props: { promptText: t('g.removeAllKeybindingsMessage') },
footerProps: {
confirmText: t('g.removeAll'),
confirmVariant: 'destructive',
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: async () => {
keybindingStore.removeAllKeybindingsForCommand(commandData.id)
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
}
}
})
}
function handleRemoveKeybindingFromMenu(commandData: ICommandData) {
if (commandData.keybindings.length >= 2) {
handleRemoveAllKeybindings(commandData)
} else {
removeSingleKeybinding(commandData, 0)
}
}
function ctxChangeKeybinding() {
if (!contextMenuTarget.value) return
const target = contextMenuTarget.value
if (target.keybindings.length === 1) {
editKeybinding(target, target.keybindings[0])
} else if (target.keybindings.length >= 2) {
if (!expandedCommandIds.value.has(target.id)) {
toggleExpanded(target.id)
}
}
}
function ctxAddKeybinding() {
if (contextMenuTarget.value) {
addKeybinding(contextMenuTarget.value)
}
}
function ctxResetToDefault() {
if (contextMenuTarget.value) {
resetKeybinding(contextMenuTarget.value)
}
}
function ctxRemoveKeybinding() {
if (
contextMenuTarget.value &&
contextMenuTarget.value.keybindings.length > 0
) {
handleRemoveKeybindingFromMenu(contextMenuTarget.value)
}
}
async function resetKeybinding(commandData: ICommandData) {
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
expandedCommandIds.value.delete(commandData.id)
await keybindingService.persistUserKeybindings()
} else {
console.warn(
@@ -189,14 +599,33 @@ async function resetKeybinding(commandData: ICommandData) {
}
const toast = useToast()
async function resetAllKeybindings() {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'All keybindings reset',
life: 3000
function resetAllKeybindings() {
const dialog = showConfirmDialog({
headerProps: {
title: t('g.resetAllKeybindingsTitle')
},
props: {
promptText: t('g.resetAllKeybindingsMessage')
},
footerProps: {
confirmText: t('g.resetAll'),
confirmVariant: 'destructive',
onCancel: () => {
dialogStore.closeDialog(dialog)
},
onConfirm: async () => {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
toast.add({
severity: 'info',
summary: t('g.info'),
detail: t('g.allKeybindingsReset'),
life: 3000
})
}
}
})
}
</script>

View File

@@ -64,9 +64,18 @@ async function handleSave() {
dialogStore.closeDialog({ key: DIALOG_KEY })
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
if (dialogState.mode === 'add') {
keybindingStore.addUserKeybinding(new KeybindingImpl({ commandId, combo }))
} else if (dialogState.existingBinding) {
keybindingStore.updateSpecificKeybinding(
dialogState.existingBinding,
new KeybindingImpl({ commandId, combo })
)
} else {
keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
}
await keybindingService.persistUserKeybindings()
}
</script>

View File

@@ -1,7 +1,10 @@
<template>
<span>
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag :severity="isModified ? 'info' : 'secondary'">
<Tag
class="bg-interface-menu-keybind-surface-default text-base-foreground"
:severity="isModified ? 'info' : 'secondary'"
>
{{ sequence }}
</Tag>
<span v-if="index < keySequences.length - 1" class="px-2">+</span>

View File

@@ -0,0 +1,111 @@
<template>
<div class="flex items-center gap-2">
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
{{ $t('g.keybindingPresets.saveChanges') }}
</Button>
<Select v-model="selectedPreset">
<SelectTrigger class="w-64">
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<div class="max-w-60">
<SelectItem
value="default"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ $t('g.keybindingPresets.default') }}
</SelectItem>
<SelectItem
v-for="name in presetNames"
:key="name"
:value="name"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ name }}
</SelectItem>
<hr class="h-px max-w-60 border border-border-default" />
<button
class="relative flex w-full max-w-60 cursor-pointer items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none select-none hover:bg-secondary-background-hover focus:bg-secondary-background-hover"
@click.stop="handleImportFromDropdown"
>
<span class="truncate">
{{ $t('g.keybindingPresets.importKeybindingPreset') }}
</span>
<i
class="icon-[lucide--file-input] shrink-0 text-base-foreground"
aria-hidden="true"
/>
</button>
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
presetNames: string[]
}>()
const emit = defineEmits<{
'presets-changed': []
}>()
const { t } = useI18n()
const keybindingStore = useKeybindingStore()
const presetService = useKeybindingPresetService()
const selectedPreset = ref(keybindingStore.currentPresetName)
const displayLabel = computed(() => {
const name =
selectedPreset.value === 'default'
? t('g.keybindingPresets.default')
: selectedPreset.value
return keybindingStore.isCurrentPresetModified ? `${name} *` : name
})
watch(selectedPreset, async (newValue) => {
if (newValue !== keybindingStore.currentPresetName) {
await presetService.switchPreset(newValue)
selectedPreset.value = keybindingStore.currentPresetName
emit('presets-changed')
}
})
watch(
() => keybindingStore.currentPresetName,
(name) => {
selectedPreset.value = name
}
)
const showSaveButton = computed(
() =>
keybindingStore.currentPresetName !== 'default' &&
keybindingStore.isCurrentPresetModified
)
async function handleSavePreset() {
await presetService.savePreset(keybindingStore.currentPresetName)
}
async function handleImportFromDropdown() {
await presetService.importPreset()
emit('presets-changed')
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex w-full max-w-[420px] flex-col border-t border-border-default"
>
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('g.keybindingPresets.unsavedChangesMessage') }}
</p>
<div class="flex justify-end gap-2">
<Button
variant="textonly"
class="text-muted-foreground"
@click="onResult(null)"
>
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
class="bg-secondary-background"
@click="onResult(false)"
>
{{ $t('g.keybindingPresets.discardAndSwitch') }}
</Button>
<Button
variant="secondary"
class="bg-base-foreground text-base-background"
@click="onResult(true)"
>
{{ $t('g.keybindingPresets.saveAndSwitch') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onResult } = defineProps<{
onResult: (result: boolean | null) => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex w-full items-center p-4">
<p class="m-0 text-sm font-medium">
{{ $t('g.keybindingPresets.unsavedChangesTo', { name: presetName }) }}
</p>
</div>
</template>
<script setup lang="ts">
const { presetName } = defineProps<{
presetName: string
}>()
</script>

View File

@@ -81,7 +81,6 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>
@@ -364,7 +363,14 @@ watch(
}
)
// Update the progress of executing nodes
/**
* Propagates execution progress from the store to LiteGraph node objects
* and triggers a canvas redraw.
*
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
* returns a new `Record` object on every progress event (the underlying
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
*/
watch(
() =>
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
@@ -382,8 +388,7 @@ watch(
// Force canvas redraw to ensure progress updates are visible
canvas.setDirty(true, false)
},
{ deep: true }
}
)
// Update node slot errors for LiteGraph nodes

View File

@@ -1,20 +1,5 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Header -->
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
@@ -40,22 +25,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
@@ -68,8 +45,6 @@ const emit = defineEmits<{
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>

View File

@@ -50,8 +50,7 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
assetType: 'output'
toggleStack: async () => {}
},
global: {
stubs: {
@@ -61,18 +60,6 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
})
describe('AssetsSidebarListView', () => {
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
})
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
})
it('marks mp4 assets as video previews', () => {
const videoAsset = {
...buildAsset('video-asset', 'clip.mp4'),

View File

@@ -1,19 +1,5 @@
<template>
<div class="flex h-full flex-col">
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
@@ -106,15 +92,13 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
toggleStack
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()

View File

@@ -24,17 +24,6 @@
</div>
</div>
</template>
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
@@ -50,15 +39,24 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
class="px-2 pb-1 2xl:px-4"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<!-- Tab list -->
<div
v-if="!isInFolderView"
class="border-b border-comfy-input p-2 2xl:px-4"
>
<TabList v-model="activeTab">
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
</div>
</template>
<template #body>
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-2"
>
<div
v-for="n in skeletonCount"
@@ -85,7 +83,11 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<div
v-else
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
<AssetsSidebarListView
v-if="isListView"
:asset-items="listViewAssetItems"
@@ -93,7 +95,6 @@
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -103,7 +104,6 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -203,7 +203,6 @@ import {
useStorage,
useTimeoutFn
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import {
computed,

View File

@@ -20,15 +20,14 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
@@ -147,6 +146,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -21,7 +21,7 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
@@ -32,7 +32,7 @@
"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<ElectronDownloadItems v-if="isDesktop" />
@@ -57,6 +57,7 @@ import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -96,12 +95,6 @@ describe('NodeLibrarySidebarTabV2', () => {
return mount(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
components: {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent
},
stubs: {
teleport: true
}
@@ -112,7 +105,7 @@ describe('NodeLibrarySidebarTabV2', () => {
it('should render with tabs', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
const triggers = wrapper.findAll('[role="tab"]')
expect(triggers).toHaveLength(3)
})

View File

@@ -1,22 +1,23 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.sort')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -42,12 +43,13 @@
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -102,65 +104,55 @@
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
<TabsList
class="bg-background flex gap-4 border-b border-comfy-input p-4"
>
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:class="
cn(
'cursor-pointer rounded-lg border-none px-3 py-2 outline-none select-none',
'text-foreground text-sm transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
: 'bg-transparent font-normal'
)
"
>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</TabsTrigger>
</TabsList>
</TabsRoot>
</Tab>
</TabList>
</div>
</template>
<template #body>
<NodeDragPreview />
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
v-if="selectedTab === 'all'"
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</div>
</div>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
@@ -170,17 +162,18 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuTrigger,
Separator,
TabsList,
TabsRoot,
TabsTrigger
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'

View File

@@ -7,9 +7,9 @@
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<div class="comfy-vue-side-bar-header flex flex-col">
<Toolbar
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-2 2xl:px-4"
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-3 2xl:px-4"
:pt="sidebarPt"
>
<template #start>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex items-center gap-2 p-2 2xl:px-4">
<div class="min-w-0 flex-1">
<slot />
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
<div v-if="bottomDivider" class="border-t border-dashed border-comfy-input" />
</template>
<script setup lang="ts">
defineProps<{
bottomDivider?: boolean
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="all" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<!-- Favorites section -->
<h3
class="mb-0 px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"
@@ -34,11 +34,10 @@
@add-to-favorites="handleAddToFavorites"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="blueprints" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
@@ -14,12 +14,10 @@
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="custom" class="flex h-full flex-1 flex-col">
<div class="flex h-full flex-1 flex-col">
<div
v-for="(section, index) in sections"
:key="section.title ?? index"
@@ -30,12 +30,10 @@
{{ $t('g.manageExtensions') }}
</Button>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -1,9 +1,5 @@
<template>
<TabsContent
ref="panelEl"
value="essentials"
class="h-full flex-1 overflow-y-auto px-3"
>
<div ref="panelEl" class="h-full flex-1 overflow-y-auto px-3">
<div class="flex flex-col gap-2 pb-6">
<!-- Flat sorted grid when alphabetical -->
<div
@@ -57,29 +53,26 @@
</CollapsibleRoot>
</template>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
TabsContent
CollapsibleTrigger
} from 'reka-ui'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
import EssentialNodeCard from './EssentialNodeCard.vue'
const panelEl = ref<HTMLDivElement | null>(null)
provide('essentialsPanelRef', panelEl)
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]

View File

@@ -1,48 +1,74 @@
<template>
<button
:id="tabId"
:class="tabClasses"
:id="`tab-${props.value}`"
role="tab"
type="button"
:aria-selected="isActive"
:aria-controls="panelId"
:tabindex="0"
:aria-controls="`tabpanel-${props.value}`"
:data-state="isActive ? 'active' : 'inactive'"
:tabindex="isActive ? 0 : -1"
:class="
cn(
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg border-none px-2.5 py-2 text-sm transition-all duration-200',
'focus-visible:ring-ring/20 outline-hidden focus-visible:ring-1',
isActive
? 'bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface',
props.class
)
"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</button>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { Ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { value, panelId } = defineProps<{
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const props = defineProps<{
value: T
panelId?: string
class?: HTMLAttributes['class']
}>()
const currentValue = inject<Ref<T>>('tabs-value')
const updateValue = inject<(value: T) => void>('tabs-update')
const context = inject(TAB_LIST_INJECTION_KEY)
const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)
const isActive = computed(() => context?.modelValue.value === props.value)
const tabClasses = computed(() => {
return cn(
// Base styles from TextButton
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg px-2.5 py-2 text-sm transition-all duration-200',
'border-none outline-hidden',
// State styles with semantic tokens
isActive.value
? 'text-bold bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
)
})
function handleClick() {
context?.select(props.value)
}
const handleClick = () => {
updateValue?.(value)
function handleKeydown(event: KeyboardEvent) {
const tablist = (event.currentTarget as HTMLElement).parentElement
if (!tablist) return
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement)
let targetIndex = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
targetIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
targetIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
targetIndex = 0
} else if (event.key === 'End') {
targetIndex = tabs.length - 1
}
if (targetIndex !== -1) {
event.preventDefault()
tabs[targetIndex].focus()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<div role="tablist" class="flex w-full items-center gap-2">
<slot />
</div>
</template>
@@ -7,11 +7,16 @@
<script setup lang="ts" generic="T extends string = string">
import { provide } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const modelValue = defineModel<T>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: T) => {
modelValue.value = value
function select(value: string) {
modelValue.value = value as T
}
provide(TAB_LIST_INJECTION_KEY, {
modelValue,
select
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div
v-if="isActive"
:id="`tabpanel-${value}`"
role="tabpanel"
tabindex="0"
:aria-labelledby="`tab-${value}`"
>
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import { computed, inject } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { value, modelValue } = defineProps<{
value: T
modelValue?: T
}>()
const context = inject(TAB_LIST_INJECTION_KEY, undefined)
const isActive = computed(() =>
modelValue !== undefined
? modelValue === value
: context?.modelValue.value === value
)
</script>

View File

@@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
interface TabListContext {
modelValue: Ref<string>
select: (value: string) => void
}
export const TAB_LIST_INJECTION_KEY: InjectionKey<TabListContext> =
Symbol('TabListContext')

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartBar from './ChartBar.vue'
const meta: Meta<typeof ChartBar> = {
title: 'Components/Chart/ChartBar',
component: ChartBar,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Bar chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'BarName1',
data: [10, 50, 35, 75],
backgroundColor: '#ff8000'
}
]
}
}
}
export const MultipleDatasets: Story = {
args: {
ariaLabel: 'Bar chart with multiple datasets',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'Series 1',
data: [30, 60, 45, 80],
backgroundColor: '#ff8000'
},
{
label: 'Series 2',
data: [50, 40, 70, 20],
backgroundColor: '#4ade80'
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
ariaLabel?: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('bar'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartLine from './ChartLine.vue'
const meta: Meta<typeof ChartLine> = {
title: 'Components/Chart/ChartLine',
component: ChartLine,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Line chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
}
]
}
}
}
export const MultipleLines: Story = {
args: {
ariaLabel: 'Line chart with multiple lines',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
},
{
label: 'LineName2',
data: [80, 60, 40, 10],
borderColor: '#ff8000',
fill: true,
backgroundColor: '#ff800033',
tension: 0.4
},
{
label: 'LineName3',
data: [60, 70, 35, 40],
borderColor: '#ef4444',
fill: true,
backgroundColor: '#ef444433',
tension: 0.4
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'line'>
options?: ChartOptions<'line'>
ariaLabel?: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('line'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,196 @@
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
)
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
}
function getDefaultOptions(type: ChartType): ChartOptions {
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
align: 'start',
labels: {
color: foreground,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
padding: 16,
font: { family: 'Inter', size: 11 },
generateLabels(chart) {
const datasets = chart.data.datasets
return datasets.map((dataset, i) => {
const color =
(dataset as { borderColor?: string }).borderColor ??
(dataset as { backgroundColor?: string }).backgroundColor ??
'#888'
return {
text: dataset.label ?? '',
fillStyle: color as string,
strokeStyle: color as string,
lineWidth: 0,
pointStyle: 'circle' as const,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i
}
})
}
}
},
tooltip: {
enabled: true
}
},
elements: {
point: {
radius: 0,
hoverRadius: 4
}
},
scales: {
x: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 8
},
grid: {
display: true,
color: muted + '33',
drawTicks: false
},
border: { display: true, color: muted }
},
y: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 4
},
grid: {
display: false,
drawTicks: false
},
border: { display: true, color: muted }
}
},
...(type === 'bar' && {
datasets: {
bar: {
borderRadius: { topLeft: 4, topRight: 4 },
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}
}
})
}
}
export function useChart(
canvasRef: Ref<HTMLCanvasElement | null>,
type: Ref<ChartType>,
data: Ref<ChartData>,
options?: Ref<ChartOptions | undefined>
) {
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
chartInstance.value?.destroy()
const defaults = getDefaultOptions(type.value)
const merged = options?.value
? deepMerge(defaults, options.value)
: defaults
chartInstance.value = new Chart(canvasRef.value, {
type: type.value,
data: data.value,
options: merged
})
}
onMounted(createChart)
watch([type, data, options ?? ref(undefined)], () => {
if (chartInstance.value) {
chartInstance.value.data = data.value
chartInstance.value.options = options?.value
? deepMerge(getDefaultOptions(type.value), options.value)
: getDefaultOptions(type.value)
chartInstance.value.update()
}
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
chartInstance.value = null
})
return { chartInstance }
}
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Record<string, unknown>
): T {
const result = { ...target } as Record<string, unknown>
for (const key of Object.keys(source)) {
const srcVal = source[key]
const tgtVal = result[key]
if (
srcVal &&
typeof srcVal === 'object' &&
!Array.isArray(srcVal) &&
tgtVal &&
typeof tgtVal === 'object' &&
!Array.isArray(tgtVal)
) {
result[key] = deepMerge(
tgtVal as Record<string, unknown>,
srcVal as Record<string, unknown>
)
} else {
result[key] = srcVal
}
}
return result as T
}

View File

@@ -0,0 +1,68 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ColorPicker from './ColorPicker.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
title: 'Components/ColorPicker',
component: ColorPicker,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#e06cbd')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Red: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ff0000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Black: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#000000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const White: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ffffff')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
class?: string
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
}
})
watch(
hsva,
(newHsva) => {
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
}
},
{ deep: true }
)
const baseRgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const previewColor = computed(() => {
const hex = rgbToHex(baseRgb.value)
const a = hsva.value.a / 100
if (a < 1) {
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, '0')
return `${hex}${alphaHex}`
}
return hex
})
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
align="start"
:side-offset="7"
:collision-padding="10"
class="z-1700"
>
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
>
<ColorPickerSaturationValue
v-model:saturation="hsva.s"
v-model:value="hsva.v"
:hue="hsva.h"
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
:saturation="hsva.s"
:brightness="hsva.v"
/>
<div class="flex items-center gap-1">
<Select v-model="displayMode">
<SelectTrigger
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
>
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-16 p-1">
<SelectItem value="hex" class="px-2 py-1 text-xs">
{{ t('color.hex') }}
</SelectItem>
<SelectItem value="rgba" class="px-2 py-1 text-xs">
{{ t('color.rgba') }}
</SelectItem>
</SelectContent>
</Select>
<div
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
</template>
<template v-else>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
ref="containerRef"
role="slider"
:aria-label="t('color.saturationBrightness')"
:aria-valuetext="`${saturation}%, ${value}%`"
class="relative aspect-square w-full cursor-crosshair rounded-sm"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const { t } = useI18n()
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
role="slider"
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="modelValue"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -204,7 +204,7 @@ function safeWidgetMapper(
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}

View File

@@ -4,6 +4,7 @@ import EditKeybindingContent from '@/components/dialog/content/setting/keybindin
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogService } from '@/services/dialogService'
@@ -13,6 +14,8 @@ export interface EditKeybindingDialogState {
commandId: string
newCombo: KeyComboImpl | null
currentCombo: KeyComboImpl | null
mode: 'edit' | 'add'
existingBinding: KeybindingImpl | null
}
export function useEditKeybindingDialog() {
@@ -23,11 +26,15 @@ export function useEditKeybindingDialog() {
commandId: string
commandLabel: string
currentCombo: KeyComboImpl | null
mode?: 'edit' | 'add'
existingBinding?: KeybindingImpl | null
}) {
const dialogState = reactive<EditKeybindingDialogState>({
commandId: options.commandId,
newCombo: options.currentCombo,
currentCombo: options.currentCombo
currentCombo: options.currentCombo,
mode: options.mode ?? 'edit',
existingBinding: options.existingBinding ?? null
})
const existingKeybindingOnCombo = computed(() => {

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -57,6 +58,11 @@ export function promoteWidget(
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
}
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Promoted widget "${widgetName}" on node ${node.id}`,
level: 'info'
})
}
export function demoteWidget(
@@ -72,6 +78,11 @@ export function demoteWidget(
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
}
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Demoted widget "${widgetName}" on node ${node.id}`,
level: 'info'
})
}
function getParentNodes(): SubgraphNode[] {
@@ -304,4 +315,9 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}

View File

@@ -0,0 +1,363 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn()
}
}))
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
class TestNode extends LGraphNode {
constructor() {
super('test')
}
}
describe('LGraphCanvas group selection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let group: LGraphGroup
let nodeA: TestNode
let nodeB: TestNode
beforeEach(() => {
vi.clearAllMocks()
graph = new LGraph()
canvas = createCanvas(graph)
group = new LGraphGroup('TestGroup')
group._bounding.set([0, 0, 500, 500])
graph.add(group)
nodeA = new TestNode()
nodeA.pos = [50, 50]
graph.add(nodeA)
nodeB = new TestNode()
nodeB.pos = [100, 100]
graph.add(nodeB)
group.recomputeInsideNodes()
})
describe('select with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('selects all children when selecting a group', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
expect(canvas.selectedItems.has(group)).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(true)
expect(canvas.selectedItems.has(nodeB)).toBe(true)
})
it('recursively selects nested group children', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
canvas.select(group)
expect(innerGroup.selected).toBe(true)
expect(innerNode.selected).toBe(true)
expect(canvas.selectedItems.has(innerGroup)).toBe(true)
expect(canvas.selectedItems.has(innerNode)).toBe(true)
})
it('selects descendants of already-selected nested groups', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
// Pre-select the inner group before selecting the outer group
canvas.select(innerGroup)
expect(innerGroup.selected).toBe(true)
expect(innerNode.selected).toBeFalsy()
canvas.select(group)
expect(innerNode.selected).toBe(true)
expect(canvas.selectedItems.has(innerNode)).toBe(true)
})
it('handles deeply nested groups (depth 5)', () => {
const groups: LGraphGroup[] = [group]
const nodes: TestNode[] = [nodeA, nodeB]
for (let depth = 1; depth <= 5; depth++) {
const offset = depth * 10
const size = 500 - depth * 20
const nestedGroup = new LGraphGroup(`Depth${depth}`)
nestedGroup._bounding.set([offset, offset, size, size])
graph.add(nestedGroup)
groups.push(nestedGroup)
const nestedNode = new TestNode()
nestedNode.pos = [offset + 5, offset + 5]
graph.add(nestedNode)
nodes.push(nestedNode)
}
// Recompute from innermost to outermost
for (let i = groups.length - 1; i >= 0; i--) {
groups[i].recomputeInsideNodes()
}
canvas.select(group)
for (const g of groups) {
expect(g.selected).toBe(true)
expect(canvas.selectedItems.has(g)).toBe(true)
}
for (const n of nodes) {
expect(n.selected).toBe(true)
expect(canvas.selectedItems.has(n)).toBe(true)
}
})
})
describe('select with groupSelectChildren disabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = false
})
it('does not select children when selecting a group', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBeFalsy()
expect(nodeB.selected).toBeFalsy()
expect(canvas.selectedItems.has(group)).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(false)
})
})
describe('deselect with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('deselects all children when deselecting a group', () => {
canvas.select(group)
expect(nodeA.selected).toBe(true)
canvas.deselect(group)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(false)
expect(nodeB.selected).toBe(false)
expect(canvas.selectedItems.has(group)).toBe(false)
expect(canvas.selectedItems.has(nodeA)).toBe(false)
})
it('recursively deselects nested group children', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
canvas.select(group)
expect(innerNode.selected).toBe(true)
canvas.deselect(group)
expect(innerGroup.selected).toBe(false)
expect(innerNode.selected).toBe(false)
})
it('handles deeply nested deselection (depth 5)', () => {
const groups: LGraphGroup[] = [group]
const nodes: TestNode[] = [nodeA, nodeB]
for (let depth = 1; depth <= 5; depth++) {
const offset = depth * 10
const size = 500 - depth * 20
const nestedGroup = new LGraphGroup(`Depth${depth}`)
nestedGroup._bounding.set([offset, offset, size, size])
graph.add(nestedGroup)
groups.push(nestedGroup)
const nestedNode = new TestNode()
nestedNode.pos = [offset + 5, offset + 5]
graph.add(nestedNode)
nodes.push(nestedNode)
}
for (let i = groups.length - 1; i >= 0; i--) {
groups[i].recomputeInsideNodes()
}
canvas.select(group)
canvas.deselect(group)
for (const g of groups) {
expect(g.selected).toBe(false)
expect(canvas.selectedItems.has(g)).toBe(false)
}
for (const n of nodes) {
expect(n.selected).toBe(false)
expect(canvas.selectedItems.has(n)).toBe(false)
}
})
})
describe('processSelect modifier-click deselect', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('modifier-click deselects only the group, not its children', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
const shiftEvent = { shiftKey: true } as CanvasPointerEvent
canvas.processSelect(group, shiftEvent)
expect(group.selected).toBe(false)
expect(canvas.selectedItems.has(group)).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(true)
expect(canvas.selectedItems.has(nodeB)).toBe(true)
})
it('ctrl-click deselects only the group, not its children', () => {
canvas.select(group)
const ctrlEvent = { ctrlKey: true } as CanvasPointerEvent
canvas.processSelect(group, ctrlEvent)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
})
})
describe('deselect with groupSelectChildren disabled', () => {
it('does not deselect children when deselecting a group', () => {
canvas.groupSelectChildren = true
canvas.select(group)
canvas.groupSelectChildren = false
canvas.deselect(group)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
})
})
describe('deleteSelected with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
// Attach canvas to DOM so checkPanels() can query parentNode
document.body.appendChild(canvas.canvas)
})
it('deletes group and all selected children', () => {
canvas.select(group)
expect(canvas.selectedItems.size).toBeGreaterThan(1)
canvas.deleteSelected()
expect(graph.nodes).not.toContain(nodeA)
expect(graph.nodes).not.toContain(nodeB)
expect(graph.groups).not.toContain(group)
})
})
})

View File

@@ -565,6 +565,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
allow_dragnodes: boolean
allow_interaction: boolean
multi_select: boolean
groupSelectChildren: boolean
allow_searchbox: boolean
allow_reconnect_links: boolean
align_to_grid: boolean
@@ -933,6 +934,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.allow_interaction = true
// allow selecting multi nodes without pressing extra keys
this.multi_select = false
this.groupSelectChildren = false
this.allow_searchbox = true
// allows to change a connection with having to redo it again
this.allow_reconnect_links = true
@@ -1951,6 +1953,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return false
}
/** Prevents default for middle-click auxclick only. */
_preventMiddleAuxClick(e: MouseEvent): void {
if (e.button === 1) e.preventDefault()
}
/** Captures an event and prevents default - returns true. */
_doReturnTrue(e: Event): boolean {
e.preventDefault()
@@ -1986,6 +1993,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
canvas.addEventListener('pointercancel', this._mousecancel_callback, true)
canvas.addEventListener('contextmenu', this._doNothing)
// Prevent middle-click paste (PRIMARY clipboard on Linux) - fixes #4464
canvas.addEventListener('auxclick', this._preventMiddleAuxClick)
// Keyboard
this._key_callback = this.processKey.bind(this)
@@ -2024,6 +2033,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
canvas.removeEventListener('keydown', this._key_callback!)
document.removeEventListener('keyup', this._key_callback!)
canvas.removeEventListener('contextmenu', this._doNothing)
canvas.removeEventListener('auxclick', this._preventMiddleAuxClick)
canvas.removeEventListener('dragenter', this._doReturnTrue)
this._mousedown_callback = undefined
@@ -4363,7 +4373,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!modifySelection) this.deselectAll(item)
this.select(item)
} else if (modifySelection && !sticky) {
this.deselect(item)
// Modifier-click toggles only the clicked item, not its children.
// Cascade on select is a convenience; cascade on deselect would
// remove the user's ability to keep children selected (e.g. for
// deletion) after toggling the group off.
if (item instanceof LGraphGroup && this.groupSelectChildren) {
item.selected = false
this.selectedItems.delete(item)
this.state.selectionChanged = true
} else {
this.deselect(item)
}
} else if (!sticky) {
this.deselectAll(item)
} else {
@@ -4388,6 +4408,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (item instanceof LGraphGroup) {
item.recomputeInsideNodes()
if (this.groupSelectChildren) {
this.#traverseGroupChildren(
item,
(child) => {
if (!child.selected || !this.selectedItems.has(child)) {
child.selected = true
this.selectedItems.add(child)
this.state.selectionChanged = true
}
},
(child) => this.select(child)
)
}
return
}
@@ -4426,6 +4459,22 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
item.selected = false
this.selectedItems.delete(item)
this.state.selectionChanged = true
if (item instanceof LGraphGroup && this.groupSelectChildren) {
this.#traverseGroupChildren(
item,
(child) => {
if (child.selected || this.selectedItems.has(child)) {
child.selected = false
this.selectedItems.delete(child)
this.state.selectionChanged = true
}
},
(child) => this.deselect(child)
)
return
}
if (!(item instanceof LGraphNode)) return
// Node-specific handling
@@ -4461,6 +4510,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
/**
* Iterative traversal of a group's descendants.
* Calls {@link groupAction} on nested groups and {@link leafAction} on
* non-group children. Always recurses into nested groups regardless of
* their current selection state.
*/
#traverseGroupChildren(
group: LGraphGroup,
groupAction: (child: LGraphGroup) => void,
leafAction: (child: Positionable) => void
): void {
const stack: Positionable[] = [...group._children]
while (stack.length > 0) {
const child = stack.pop()!
if (child instanceof LGraphGroup) {
groupAction(child)
for (const nested of child._children) stack.push(nested)
} else {
leafAction(child)
}
}
}
/** @deprecated See {@link LGraphCanvas.processSelect} */
processNodeSelected(item: LGraphNode, e: CanvasPointerEvent): void {
this.processSelect(
@@ -4593,7 +4665,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.emitBeforeChange()
graph.beforeChange()
for (const item of this.selectedItems) {
// Snapshot to prevent mutation during iteration (e.g. group deselect cascade)
const toDelete = [...this.selectedItems]
for (const item of toDelete) {
if (item instanceof LGraphNode) {
const node = item
if (node.block_delete) continue

View File

@@ -1,9 +1,12 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LGraphEventMode,
ExecutableNodeDTO
} from '@/lib/litegraph/src/litegraph'
@@ -249,6 +252,136 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
})
})
describe('Muted node output resolution', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should return undefined for NEVER mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Muted Node')
node.addOutput('out', 'string')
node.mode = LGraphEventMode.NEVER
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
const resolved = dto.resolveOutput(0, 'string', new Set())
expect(resolved).toBeUndefined()
})
it('should return undefined for muted subgraph nodes without throwing', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }],
nodeCount: 1
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.mode = LGraphEventMode.NEVER
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
const nodesByExecutionId = new Map()
const dto = new ExecutableNodeDTO(
subgraphNode,
[],
nodesByExecutionId,
undefined
)
nodesByExecutionId.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
})
it('should resolve undefined when input is connected to a muted node', () => {
const graph = new LGraph()
const mutedNode = new LGraphNode('Muted Node')
mutedNode.addOutput('result', 'IMAGE')
mutedNode.mode = LGraphEventMode.NEVER
graph.add(mutedNode)
const downstreamNode = new LGraphNode('Downstream')
downstreamNode.addInput('input', 'IMAGE')
graph.add(downstreamNode)
mutedNode.connect(0, downstreamNode, 0)
const nodeDtoMap = new Map()
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
nodeDtoMap.set(mutedDto.id, mutedDto)
const downstreamDto = new ExecutableNodeDTO(
downstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(downstreamDto.id, downstreamDto)
const resolved = downstreamDto.resolveInput(0)
expect(resolved).toBeUndefined()
})
})
describe('Bypass node output resolution', () => {
it('should still resolve bypass for BYPASS mode nodes', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('in', 'IMAGE')
bypassedNode.addOutput('out', 'IMAGE')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 0)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(upstreamDto)
})
})
describe('ALWAYS mode node output resolution', () => {
it('should attempt normal resolution for ALWAYS mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Normal Node')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.ALWAYS
graph.add(node)
const nodeDtoMap = new Map()
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
nodeDtoMap.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(dto)
expect(resolved?.origin_slot).toBe(0)
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()

View File

@@ -266,6 +266,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
}
visited.add(uniqueId)
// Muted nodes produce no output
if (this.mode === LGraphEventMode.NEVER) return
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
// Bypass nodes by finding first input with matching type

View File

@@ -862,7 +862,7 @@
"promptExecutionError": "فشل تنفيذ الطلب"
},
"errorOverlay": {
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
"errorCount": "{count} خطأ | {count} أخطاء",
"seeErrors": "عرض الأخطاء"
},
"essentials": {
@@ -909,7 +909,7 @@
"downloadFailed": "فشل تحميل \"{name}\"",
"exportCompleted": "تحميل ZIP جاهز",
"exportError": "فشل التصدير",
"exportFailed": "{count} فشل في التصدير | {count} فشل في التصدير | {count} عمليات تصدير فشلت",
"exportFailed": "{count} فشل في التصدير | {count} عمليات تصدير فشلت",
"exportFailedSingle": "فشل إنشاء تصدير ZIP",
"exportStarted": "يتم تجهيز تحميل ZIP...",
"exportingAssets": "جاري تصدير العناصر",
@@ -928,7 +928,7 @@
"amount": "الكمية",
"apply": "تطبيق",
"architecture": "الهندسة المعمارية",
"asset": "{count} أصل | {count} أصل | {count} أصول",
"asset": "{count} أصل | {count} أصول",
"audioFailedToLoad": "فشل تحميل الصوت",
"audioProgress": "تقدم الصوت",
"author": "المؤلف",
@@ -1114,7 +1114,7 @@
"nodeSlotsError": "خطأ في فتحات العقدة",
"nodeWidgetsError": "خطأ في عناصر واجهة العقدة",
"nodes": "العُقَد",
"nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة",
"nodesCount": "{count} عقدة | {count} عقدة",
"nodesRunning": "العُقَد قيد التشغيل",
"none": "لا شيء",
"nothingToCopy": "لا يوجد ما يمكن نسخه",
@@ -2295,7 +2295,7 @@
"progressToast": {
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
"downloadingModel": "جاري تنزيل النموذج...",
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل | {count} فشل في التنزيل",
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل",
"failed": "فشل",
"filter": {
"all": "الكل",

View File

@@ -11306,6 +11306,35 @@
}
}
},
"Painter": {
"display_name": "الرسام",
"inputs": {
"bg_color": {
"name": "لون الخلفية"
},
"height": {
"name": "الارتفاع"
},
"image": {
"name": "الصورة",
"tooltip": "صورة أساسية اختيارية للرسم فوقها"
},
"mask": {
"name": "mask"
},
"width": {
"name": "العرض"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"PairConditioningCombine": {
"display_name": "دمج زوج الشرط",
"inputs": {

View File

@@ -69,6 +69,17 @@
"resetAll": "Reset All",
"clearFilters": "Clear Filters",
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
"resetAllKeybindingsTitle": "Reset all keybindings?",
"resetAllKeybindingsMessage": "All keybindings will be reset to their default values. This cannot be undone.",
"allKeybindingsReset": "All keybindings reset to default",
"removeAllKeybindingsTitle": "Remove all keybindings?",
"removeAllKeybindingsMessage": "All keybindings for this command will be removed",
"removeAll": "Remove all",
"changeKeybinding": "Change keybinding",
"addNewKeybinding": "Add new keybinding",
"resetToDefault": "Reset to default",
"removeKeybinding": "Remove keybinding",
"nMoreKeybindings": "+ {count} more",
"customizeFolder": "Customize Folder",
"icon": "Icon",
"color": "Color",
@@ -271,6 +282,32 @@
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
"keybindingAlreadyExists": "Keybinding already exists on",
"keybindingPresets": {
"importPreset": "Import preset",
"importKeybindingPreset": "Import keybinding preset",
"exportPreset": "Export preset",
"saveChanges": "Save Changes",
"saveAsNewPreset": "Save as new preset",
"resetToDefault": "Reset to default",
"deletePreset": "Delete preset",
"unsavedChangesTo": "Unsaved changes to {name}",
"unsavedChangesMessage": "You have unsaved changes that will be lost if you switch without saving.",
"discardAndSwitch": "Discard and Switch",
"saveAndSwitch": "Save and Switch",
"deletePresetTitle": "Delete the current preset?",
"deletePresetWarning": "This preset will be deleted. This cannot be undone.",
"presetSaved": "Preset \"{name}\" saved",
"presetDeleted": "Preset \"{name}\" deleted",
"presetImported": "Keybinding preset imported",
"invalidPresetFile": "Preset file must be valid JSON exported from ComfyUI",
"invalidPresetName": "Preset name must not be empty, \"default\", start with a dot, contain path separators, or end with .json",
"loadPresetFailed": "Failed to load preset \"{name}\"",
"deletePresetFailed": "Failed to delete preset \"{name}\"",
"overwritePresetTitle": "Overwrite Preset",
"overwritePresetMessage": "A preset named \"{name}\" already exists. Overwrite it?",
"presetNamePrompt": "Enter a name for the preset",
"default": "Default Preset"
},
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",
@@ -496,7 +533,12 @@
"cyan": "Cyan",
"purple": "Purple",
"black": "Black",
"custom": "Custom"
"custom": "Custom",
"hex": "Hex",
"rgba": "RGBA",
"saturationBrightness": "Color saturation and brightness",
"hue": "Hue",
"alpha": "Alpha"
},
"contextMenu": {
"Inputs": "Inputs",
@@ -2511,7 +2553,7 @@
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"create": "Create"

View File

@@ -11306,6 +11306,35 @@
}
}
},
"Painter": {
"display_name": "Painter",
"inputs": {
"mask": {
"name": "mask"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"bg_color": {
"name": "bg_color"
},
"image": {
"name": "image",
"tooltip": "Optional base image to paint over"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"PairConditioningCombine": {
"display_name": "Cond Pair Combine",
"inputs": {

View File

@@ -862,7 +862,7 @@
"promptExecutionError": "La ejecución del prompt falló"
},
"errorOverlay": {
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
"errorCount": "{count} ERROR | {count} ERRORES",
"seeErrors": "Ver errores"
},
"essentials": {
@@ -909,7 +909,7 @@
"downloadFailed": "No se pudo descargar \"{name}\"",
"exportCompleted": "Descarga ZIP lista",
"exportError": "Error en la exportación",
"exportFailed": "{count} exportación fallida | {count} exportación fallida | {count} exportaciones fallidas",
"exportFailed": "{count} exportación fallida | {count} exportaciones fallidas",
"exportFailedSingle": "No se pudo crear la exportación ZIP",
"exportStarted": "Preparando descarga ZIP...",
"exportingAssets": "Exportando recursos",
@@ -928,7 +928,7 @@
"amount": "Cantidad",
"apply": "Aplicar",
"architecture": "Arquitectura",
"asset": "{count} recursos | {count} recurso | {count} recursos",
"asset": "{count} recurso | {count} recursos",
"audioFailedToLoad": "No se pudo cargar el audio",
"audioProgress": "Progreso de audio",
"author": "Autor",
@@ -1114,7 +1114,7 @@
"nodeSlotsError": "Error de Ranuras del Nodo",
"nodeWidgetsError": "Error de Widgets del Nodo",
"nodes": "Nodos",
"nodesCount": "{count} nodos | {count} nodo | {count} nodos",
"nodesCount": "{count} nodo | {count} nodos",
"nodesRunning": "nodos en ejecución",
"none": "Ninguno",
"nothingToCopy": "Nada para copiar",
@@ -2295,7 +2295,7 @@
"progressToast": {
"allDownloadsCompleted": "Todas las descargas completadas",
"downloadingModel": "Descargando modelo...",
"downloadsFailed": "{count} descargas fallidas | {count} descarga fallida | {count} descargas fallidas",
"downloadsFailed": "{count} descarga fallida | {count} descargas fallidas",
"failed": "Fallido",
"filter": {
"all": "Todo",

View File

@@ -11306,6 +11306,35 @@
}
}
},
"Painter": {
"display_name": "Pintor",
"inputs": {
"bg_color": {
"name": "color_de_fondo"
},
"height": {
"name": "alto"
},
"image": {
"name": "imagen",
"tooltip": "Imagen base opcional para pintar encima"
},
"mask": {
"name": "mask"
},
"width": {
"name": "ancho"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"PairConditioningCombine": {
"display_name": "CombinarCondPares",
"inputs": {

View File

@@ -928,7 +928,7 @@
"amount": "مقدار",
"apply": "اعمال",
"architecture": "معماری",
"asset": "{count} دارایی | {count} دارایی | {count} دارایی",
"asset": "{count} دارایی | {count} دارایی",
"audioFailedToLoad": "بارگذاری صوت ناموفق بود",
"audioProgress": "پیشرفت صوت",
"author": "نویسنده",
@@ -1114,7 +1114,7 @@
"nodeSlotsError": "خطا در slotهای node",
"nodeWidgetsError": "خطا در ابزارک‌های node",
"nodes": "nodeها",
"nodesCount": "{count} نود | {count} نود | {count} نود",
"nodesCount": "{count} نود | {count} نود",
"nodesRunning": "nodeها در حال اجرا هستند",
"none": "هیچ‌کدام",
"nothingToCopy": "موردی برای کپی وجود ندارد",
@@ -2295,7 +2295,7 @@
"progressToast": {
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
"downloadingModel": "در حال دانلود مدل...",
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
"failed": "ناموفق",
"filter": {
"all": "همه",

View File

@@ -11306,6 +11306,35 @@
}
}
},
"Painter": {
"display_name": "نقاش",
"inputs": {
"bg_color": {
"name": "رنگ پس‌زمینه"
},
"height": {
"name": "ارتفاع"
},
"image": {
"name": "تصویر",
"tooltip": "تصویر پایه اختیاری برای نقاشی روی آن"
},
"mask": {
"name": "mask"
},
"width": {
"name": "عرض"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"PairConditioningCombine": {
"display_name": "ترکیب جفت شرطی",
"inputs": {

View File

@@ -862,7 +862,7 @@
"promptExecutionError": "L'exécution de l'invite a échoué"
},
"errorOverlay": {
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
"errorCount": "{count} ERREUR | {count} ERREURS",
"seeErrors": "Voir les erreurs"
},
"essentials": {
@@ -909,7 +909,7 @@
"downloadFailed": "Échec du téléchargement de « {name} »",
"exportCompleted": "Téléchargement ZIP prêt",
"exportError": "Échec de lexportation",
"exportFailed": "{count} exportation échouée | {count} exportation échouée | {count} exportations échouées",
"exportFailed": "{count} exportation échouée | {count} exportations échouées",
"exportFailedSingle": "Échec de la création de lexport ZIP",
"exportStarted": "Préparation du téléchargement ZIP...",
"exportingAssets": "Exportation des ressources",
@@ -928,7 +928,7 @@
"amount": "Quantité",
"apply": "Appliquer",
"architecture": "Architecture",
"asset": "{count} ressources | {count} ressource | {count} ressources",
"asset": "{count} ressource | {count} ressources",
"audioFailedToLoad": "Échec du chargement de l'audio",
"audioProgress": "Progression audio",
"author": "Auteur",
@@ -1114,7 +1114,7 @@
"nodeSlotsError": "Erreur d'emplacements du nœud",
"nodeWidgetsError": "Erreur de widgets du nœud",
"nodes": "Nœuds",
"nodesCount": "{count} nœuds | {count} nœud | {count} nœuds",
"nodesCount": "{count} nœud | {count} nœuds",
"nodesRunning": "nœuds en cours dexécution",
"none": "Aucun",
"nothingToCopy": "Rien à copier",
@@ -2295,7 +2295,7 @@
"progressToast": {
"allDownloadsCompleted": "Tous les téléchargements sont terminés",
"downloadingModel": "Téléchargement du modèle...",
"downloadsFailed": "{count} téléchargements échoués | {count} téléchargement échoué | {count} téléchargements échoués",
"downloadsFailed": "{count} téléchargement échoué | {count} téléchargements échoués",
"failed": "Échec",
"filter": {
"all": "Tous",

View File

@@ -11306,6 +11306,35 @@
}
}
},
"Painter": {
"display_name": "Peintre",
"inputs": {
"bg_color": {
"name": "couleur de fond"
},
"height": {
"name": "hauteur"
},
"image": {
"name": "image",
"tooltip": "Image de base optionnelle sur laquelle peindre"
},
"mask": {
"name": "mask"
},
"width": {
"name": "largeur"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"PairConditioningCombine": {
"display_name": "Cond Pair Combiner",
"inputs": {

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