## Summary
- Drop the `--` separator from all in-repo `pnpm test:unit -- <args>`
examples. The separator is unnecessary (pnpm forwards extra args
automatically) and on Windows PowerShell it mangles quoted args like `-t
"restores host values by input name"`, splitting them into multiple
tokens.
- Add a short note in `docs/guidance/vitest.md` explaining the
substring-match semantics of the positional filter and that `-t` matches
`it()`/`test()` names only (not `describe()` blocks).
- Fix `pnpm test:unit -- run <files>` in the backport-management skill:
because `test:unit` is already `vitest run`, the literal `run` token was
a positional path filter that silently narrowed the suite to files whose
paths contain "run".
## Test plan
- [ ] `pnpm test:unit useConflictAcknowledgment` matches
`useConflictAcknowledgment.test.ts`
- [ ] `pnpm test:unit SubgraphWidgetPromotion.test.ts -t "restores host
values"` filters to a single test
- [ ] `git grep "pnpm test:unit -- "` returns no in-repo matches
#8505 added support for specifying default values for
`control_after_generate`. Unbeknown to me, this exact same format of
assigning `control_after_generate` to a string in the schema already
served a function of renaming the control widget. As a result, control
widgets with a default value set would use a different internal name,
but due to other overlapping systems, would either have a label of
`control_after_generate` or `control_before_generate`.
The fix here, is incredibly simple and low scope. Instead of trying to
filter control widgets by name, the dedicated `IS_CONTROL_WIDGET` symbol
is used.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5917e093-124a-4923-80ff-321fc0a94ef3"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c6d95b5a-2764-4e71-a09f-dcae5ddcfdbb"
/>|
Legacy files wrote `widgets_values` in promotion order; the new loader
applies them positionally against `subgraph.inputs` order, so mismatched
orderings shift values onto the wrong widgets. Prefer
`proxyWidgetErrorQuarantine` host values keyed by subgraph input name
(`sourceNodeId === '-1'`), falling back to positional `widgets_values`
only when no quarantine entry exists.
Amp-Thread-ID: https://ampcode.com/threads/T-019e61be-9028-7573-9eca-ceca292a9f43
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Bump required Node.js to `>=25` and pnpm to `>=11.3`.
## Changes
- **What**: Updated `engines.node` to `>=25`, `engines.pnpm` to
`>=11.3`, and `packageManager` to `pnpm@11.3.0` in `package.json`.
Bumped `.nvmrc` from `24` to `25`.
- **Breaking**: Contributors and CI must now use Node 25+ and pnpm
11.3+.
## Review Focus
CI workflows resolve Node from `.nvmrc`, so they pick up the bump
automatically. Confirm no downstream tooling pins an older Node/pnpm.
---------
Co-authored-by: Amp <amp@ampcode.com>
*PR Created by the Glary-Bot Agent*
---
Group Nodes are a legacy feature superseded by Subgraphs. This PR
removes every UI entry point for *creating* a new Group Node while
keeping the loading, ungrouping, and management code intact so existing
workflows that contain Group Nodes continue to load and can still be
unpacked or managed.
## Removed creation entry points
- `Comfy.GroupNode.ConvertSelectedNodesToGroupNode` command
- `Alt+G` keybinding
- "Convert to Group Node (Deprecated)" canvas and node right-click menu
items (`groupNode.ts` `getCanvasMenuItems` / `getNodeMenuItems`)
- "Convert to Group Node" entry in the Vue selection menu
(`useSelectionMenuOptions.ts`)
- Associated `MENU_ORDER` entry in `contextMenuConverter.ts`
- `convertSelectedNodesToGroupNode` / `convertDisabled` helpers in
`groupNode.ts`
- `BadgeVariant.DEPRECATED` enum member (no remaining consumers;
knip-clean)
- Matching `en` locale strings in `main.json` (`contextMenu.Convert to
Group Node`, `commands.Convert selected nodes to group node`) and
`commands.json` (`Comfy_GroupNode_ConvertSelectedNodesToGroupNode`)
- Browser-test helpers `convertToGroupNode` /
`convertAllNodesToGroupNode` and the three tests that exercised the
creation flow
## Preserved (intentionally)
- `GroupNodeHandler`, `GroupNodeConfig`, `GroupNodeBuilder`,
`ManageGroupDialog`
- `beforeConfigureGraph` / `nodeCreated` hooks that load and initialize
Group Nodes from saved workflows
- "Manage Group Nodes" canvas menu item, the
`Comfy.GroupNode.ManageGroupNodes` command, and the per-node "Manage
Group Node" / "Convert to nodes" options on existing group node
instances
- "Ungroup selected group nodes" command + `Alt+Shift+G` keybinding so
users can disassemble existing group nodes in legacy workflows
- Reduced `browser_tests/tests/groupNode.spec.ts` covering surviving
behaviors: workflow loading (legacy `/` separator, hidden-input config,
v1.3.3 fixture), copy/paste of already-loaded group nodes across
workflows, and opening the Manage Group Node dialog
## Verification
- `pnpm typecheck` clean
- `pnpm typecheck:browser` clean
- `pnpm format` clean
- `pnpm knip` clean (no new findings; pre-existing flac.ts tag warning
unchanged)
- `pnpm test:unit` — 796 files, 10,789 tests pass (8 pre-existing
skipped); includes a regression test in
`useSelectionMenuOptions.test.ts` asserting the Vue selection menu no
longer offers a Convert to Group Node option
- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:browser)
passed
- Manual verification against a live dev server: programmatically
inspecting the GroupNode extension showed `getCanvasMenuItems` returns
only `[Manage Group Nodes]`, `getNodeMenuItems` returns `[]`, and the
`ConvertSelectedNodesToGroupNode` command + Alt+G keybinding are absent
from the registries. Visually captured the node right-click menu
(attached screenshot) — "Convert to Subgraph" remains, no "Convert to
Group Node" entry
- Browser E2E suite not executed locally (sandbox has no GPU and
Playwright requires a full backend; the reduced spec will run in CI)
- Non-English locales not modified — per `src/locales/CONTRIBUTING.md`
they are regenerated by CI
## Notes for reviewers
- This is a surgical removal of creation only; loading any older
workflow that already contains group nodes will continue to work.
- If you'd like to also remove the management UI (`Manage Group Nodes`
command/menu/dialog) or the ungroup command in a follow-up, happy to
open a separate PR.
## Screenshots

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12347-feat-remove-ability-to-create-Group-Nodes-3656d73d365081d488bfd98ffd7545c0)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Serve separate SVG favicons via prefers-color-scheme so the icon stays
legible against both light and dark browser chrome. Drop the
unreferenced favicon.svg / favicon.png; keep favicon.ico as the legacy
fallback.
## Changes
- What: apps/website/src/layouts/BaseLayout.astro now links
favicon-light.svg and favicon-dark.svg gated on prefers-color-scheme,
with favicon.ico retained as the legacy fallback. Unreferenced
favicon.svg / favicon.png removed from apps/website/public/.
## Review Focus
- Naming convention: favicon-light.svg is the asset served in light mode
(dark-backgrounded icon for contrast against light chrome);
favicon-dark.svg is served in dark mode. Confirm this matches
expectation.
- Safari fallback: older Safari versions ignore prefers-color-scheme on
<link rel="icon"> and will fall through to favicon.ico — that file is
unchanged and should look acceptable in both modes.
## Screenshots
Dark mode:
<img width="224" height="30" alt="image"
src="https://github.com/user-attachments/assets/5fa3c620-0021-4c90-bc18-013cd6ef45cf"
/>
Light mode:
<img width="227" height="28" alt="image"
src="https://github.com/user-attachments/assets/54a130e1-f976-46e8-b047-e27efe22e479"
/>
Mask editor checks `node.images` to determine the image which is edited.
If the user generates an output image in litegraph mode, swaps to vue
mode, then generates a new image, the mask editor will incorrectly
display the image last shown in litegraph mode.
This is resolved by having `syncLegacyNodeImgs` also synchronize node
outputs to `node.images`.
## Summary
Drop a no-op cn() wrapper on the static camera-switch icon and trim the
two over-long comments around setRetainViewOnReload / _loadModelInternal
down to one line each.
## Summary
Fix the duplicate \`<WidgetColorPicker>\` rendering on the \`Color to
RGB Int\` node (and any other COLOR-using V3 node that the runtime
double-registers a widget for).
<img width="480" alt="after-fix-dedupe-proof"
src="https://github.com/user-attachments/assets/5c801806-ed5d-493f-92b6-e0b99dd8e408"
/>
## Changes
- **What**:
- \`useProcessedWidgets.getWidgetIdentity\`: fall back to the host
\`nodeId\` parameter for the dedupe identity root when neither
\`storeNodeId/widget.nodeId\` nor \`sourceExecutionId\` is set. Normal
root-graph widgets now dedupe identically to promoted/execution-scoped
widgets, so any duplicate same-name+same-type widget collapses to one
render. \`sourceExecutionId\` precedence is preserved.
- \`useColorWidget\`: read top-level \`default\` from the V2 spec (falls
back to nested \`options.default\` for hand-authored V2 specs), and
short-circuit if a same-name color widget already exists on
\`node.widgets\` so a second \`addWidget('color', …)\` call from
upstream hooks (or a \`configure\` round-trip) no longer duplicates the
row.
- **Tests**:
- New \`useColorWidget.test.ts\` covers top-level default,
nested-options fallback, no-default fallback, and the idempotency guard.
- \`useProcessedWidgets.test.ts\` gets a regression case for two
identical color widgets on the same node collapsing to one render, plus
an updated \`getWidgetIdentity\` case for the host-nodeId fallback.
## Review Focus
- \`getWidgetIdentity\` precedence change. The fallback only fires when
none of \`storeNodeId\`, \`widget.nodeId\`, or \`sourceExecutionId\` are
present, so promoted/exec-scoped widgets (incl. the \"unresolved
same-name promoted entries distinct by source execution identity\"
\`NodeWidgets\` test) are unaffected.
- \`useColorWidget\` idempotency guard is defensive — the root cause of
the second \`addWidget\` call (cloud-only hook or persisted
\`info.widgets\` configure round-trip) is not in this diff; that's
tracked separately.
Fixes
[FE-842](https://linear.app/comfyorg/issue/FE-842/color-to-rgb-int-node-shows-duplicate-color-widgets)
When comparing outputs from 3D generations, it's very hard to see small
differences since the camera always resets. This adds an option to lock
the camera, so only the model refreshes.
## Summary
Adds an opt-in per-node toggle that preserves the current camera view
(position, target, zoom, camera type) across model loads in Load3D /
Load3DAnimation nodes, instead of resetting to default framing.
## Changes
- **What**: New `retainViewOnReload?: boolean` field on `CameraConfig`,
a `Load3d.setRetainViewOnReload()` setter wired through the existing
`useLoad3d` camera-config watcher, capture/restore logic in
`Load3d._loadModelInternal`, and a lock-icon toggle button in
`CameraControls.vue` below the FOV slider. Preference persists via the
existing `node.properties['Camera Config']` mechanism.
## Review Focus
- **First-load semantics**: retain only kicks in once a model has
successfully loaded at least once (`hasLoadedModel` flag), so the
default `setupForModel` framing wins on a fresh node. `clearModel()`
resets the flag so the next load also reframes.
- **Restore order vs. `SceneModelManager.setupModel`**: the scene model
manager unconditionally calls `setupForModel` during a load, which
clobbers the camera. The restore in `_loadModelInternal` runs *after*
the load completes, on top of that framing.
- **Camera-type mismatch**: if the saved state's `cameraType` differs
from the currently active camera, `toggleCamera()` runs before
`setCameraState()` so the perspective/orthographic camera being restored
is actually the active one. Covered by a dedicated test.
- **Scope**: only wired through `useLoad3d` (LiteGraph node controls).
The full-page viewer (`useLoad3dViewer` / `ViewerCameraControls`) is
deliberately not extended — the modal is mostly a one-shot
view-and-close flow, so retain there would add surface area for an
uncommon use case.
- **Failed loads**: `hasLoadedModel` only flips inside `if
(modelManager.currentModel)`, so a load that produces no model leaves
the flag where it was. Captured camera state is still applied on top,
which effectively no-ops since nothing reset it.
## Video
https://github.com/user-attachments/assets/880d6ad1-28a9-4413-83a3-8323d05d904a
Vue/PrimeVue context menu replaces .litecontextmenu when Vue nodes are enabled. Delegating to ContextMenu.clickMenuItem (getByRole('menuitem')) matches both menu implementations, fixing convertToSubgraph and related callers in @vue-nodes specs.
Under ADR 0009 host-wins, PromotedWidgetView no longer mutates the
interior widget on edit. The subgraph wrapper's credits badge was still
computed against the inner LGraphNode's raw widget value and so stayed
frozen at the conversion-time price when the user changed a promoted
combo on the wrapper.
- getNodeDisplayPrice / buildJsonataContext accept an optional
widgetOverrides map so callers can supply effective widget values
without mutating the inner widget.
- For a SubgraphNode wrapping a single api node, updateSubgraphCredits
now pushes a wrapper-aware badge getter that builds overrides from
the wrapper's PromotedWidgetView host values and calls
getNodeDisplayPrice with them. The legacy static / multi-node
branches are preserved.
- usePartitionedBadges touches each PromotedWidgetView's host value and
the inner api node's pricing inputs inside the wrapper's badge
computed so a promoted edit invalidates the wrapper's badge.
priceBadge.spec.ts tag converted to the typed `{ tag: [...] }` form.
Amp-Thread-ID: https://ampcode.com/threads/T-019e5248-7986-77ae-a78b-41cd08c5af38
Co-authored-by: Amp <amp@ampcode.com>
## Summary
This PR introduces the frontend error catalog display resolver as the
foundation for the DES-220 / FE-816 error messaging work.
The main goal is to create a single FE boundary where raw Core/Cloud
error payloads can be converted into human-friendly display fields,
while preserving the original API contract fields (`message` and
`details`) unchanged. UI components can now prefer resolved display copy
when it exists and fall back to the raw API copy otherwise.
As a small concrete sample, this PR implements the first cataloged
validation error:
- `required_input_missing` is resolved as the `missing_connection`
catalog item.
- Panel title: `Missing connection`
- Panel message: `Required input slots have no connection feeding them.`
- Detail/item copy can include the node and input name, e.g. `KSampler
is missing a required input: model` and `KSampler - model`.
- Single-error toast/overlay-oriented fields are added to the data model
for follow-up UI work, but this PR does not redesign the overlay.
## What This PR Targets
This PR is intentionally scoped as the skeleton PR for the error catalog
UX system.
It adds:
- A new resolver module under `src/platform/errorCatalog`.
- Shared resolved display fields:
- `catalogId`
- `displayTitle`
- `displayMessage`
- `displayDetails`
- `displayItemLabel`
- `toastTitle`
- `toastMessage`
- A resolver entry point for run-time workflow errors:
- node validation errors
- execution/runtime errors
- prompt errors
- A resolver entry point for pre-run missing-resource groups:
- missing node packs
- swap nodes
- missing models
- missing media
- Error group wiring so `useErrorGroups` resolves display copy in one
place instead of making UI components own message decisions.
- The first real validation rule for `required_input_missing` / Missing
connection.
- The existing prompt error copy moved into the
`errorCatalog.promptErrors` namespace in `src/locales/en/main.json`.
- Tests covering the resolver, grouping behavior, panel rendering,
prompt error copy, missing group copy, and fallback behavior.
## What This PR Deliberately Does Not Target
This PR avoids the larger UX and product behavior changes so the
foundation can land separately.
It does not:
- Redesign the error overlay.
- Redesign the right-side error panel.
- Change the shape of Core/Cloud API error payloads.
- Replace raw `message` / `details`; those remain intact for
API-contract alignment and technical debugging.
- Re-group execution errors by final message type yet.
- Add special runtime error messaging for credits, timeouts, content
policy, OOM, or rate limits.
- Render the new `displayItemLabel` everywhere it will eventually be
useful.
## User-Facing Behavior
Most behavior is preserved.
The main visible change is for missing required input validation errors.
Those now display as Missing connection copy instead of exposing the raw
validation message directly.
Prompt errors should keep the same user-facing wording as before, but
the source of that wording now lives under the error catalog namespace.
Missing node/model/media/swap-node groups still preserve the existing
titles, counts, and friendly messages, but their display copy now flows
through the same resolver boundary.
Execution/runtime errors receive catalog fields for future toast/overlay
usage, but the current runtime overlay path intentionally keeps the raw
technical error copy until the overlay redesign PR decides how to
consume the new fields.
## Screenshots
Before
<img width="505" height="266" alt="스크린샷 2026-05-22 오후 2 15 27"
src="https://github.com/user-attachments/assets/09e8eb31-dca4-42d8-8237-9474cb71a14c"
/>
<img width="463" height="317" alt="스크린샷 2026-05-22 오후 2 16 09"
src="https://github.com/user-attachments/assets/c0a0159e-5bd9-4b3f-9c21-c0040373fbca"
/>
After
<img width="482" height="297" alt="스크린샷 2026-05-22 오후 2 14 25"
src="https://github.com/user-attachments/assets/4ca10bf0-29d2-4b65-940e-0d78db3fd278"
/>
<img width="460" height="194" alt="스크린샷 2026-05-22 오후 2 16 55"
src="https://github.com/user-attachments/assets/20848054-5012-4dd3-b903-ef8c920f70c8"
/>
## Follow-Up PR Plan
This PR is the first stacked PR in the error catalog work. Follow-up PRs
are expected to build on this foundation in roughly this order:
1. Expand general execution error messaging.
- Add broader validation error handling beyond `required_input_missing`,
including list/range/value validation cases.
- Add general runtime execution messaging.
- Continue migrating prompt error display decisions into the catalog
resolver.
2. Add special runtime error messaging.
- Credits / insufficient credits.
- Timeout.
- Content not allowed / blocked content.
- Server crash.
- Out of memory.
- Rate limiting.
- Other high-volume Cloud-only runtime failures from DES-220.
3. Re-group execution errors by message/catalog type.
- Move away from grouping primarily by node class when the cataloged
error type is the more useful user-facing grouping key.
- Keep raw technical details available inside cards/logs.
4. Update the error overlay behavior.
- Use `toastTitle` and `toastMessage` for single-error cases.
- Use aggregate copy such as "N errors found" for multi-error cases.
- Add node navigation affordances where appropriate.
5. Update the right-side error panel design.
- Render resolved item labels such as `Node name - Input name`.
- Align expanded card details and logs with the new design.
- Preserve copy/debug affordances for technical details.
6. Fold in related missing media/model/node messaging improvements.
- FE-583 should become a child/follow-up issue in this stack for
improving missing image/media messaging.
## Validation
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- Targeted resolver/grouping tests during review iterations
- `pnpm knip`
`pnpm knip` passes with only the pre-existing tag hint:
`Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer →
@knipIgnoreUnusedButUsedByCustomNodes`
Convert subgraph lifecycle, promotion, promotion-DOM, and serialization
specs to query the Vue node DOM (vueNodes.getNodeLocator + role-based
locators) instead of canvas-era selectors, and tag the affected cases
with @vue-nodes.
Amp-Thread-ID: https://ampcode.com/threads/T-019e5248-7986-77ae-a78b-41cd08c5af38
Co-authored-by: Amp <amp@ampcode.com>
Add max-w-9xl mx-auto to section/container wrappers across the website
so layout stays centered and capped at 96rem on screens wider than
1536px.
## Summary
<!-- One sentence describing what changed and why. -->
## Changes
- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->
## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
## Screenshots (if applicable)
<!-- Add screenshots or video recording to help explain your changes -->
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
This PR fixes the remaining FE-367 workflow persistence gap by moving
the workflow draft lifecycle callers from the legacy V1 draft store to
`workflowDraftStoreV2`, following the core design from #10367 while
omitting unrelated changes.
It keeps the change focused on saved workflow tab restore and V2 draft
lifecycle behavior:
- save active workflow drafts through V2 before loading a new graph
- load, save, save-as, close, rename, and delete workflows against V2
draft storage
- prefer a fresh V2 draft when loading a saved workflow, and discard
stale drafts when the remote workflow is newer
- restore saved open tabs from persisted tab state instead of letting
stale active-path state win
- preserve V2 draft payload timestamps when moving or refreshing draft
recency
- remove the now-unused V1 draft store/cache implementation instead of
suppressing knip; the raw V1 on-disk migration path remains for existing
users
Co-authored-by: xmarre <xmarre@users.noreply.github.com>
## Test coverage
Added unit coverage for V2 draft load, stale draft discard, rename/close
lifecycle cleanup, tab restore ordering, metadata-load waiting/fallback,
draft recency updates, quota eviction retry, and persistence-disabled
reset behavior.
Updated the workflow persistence composable tests to use a real
`vue-i18n` plugin host instead of mocking `vue-i18n`.
Added an E2E regression test that saves two workflows, edits an inactive
saved tab draft, makes the active-path pointer stale, reloads, and
verifies the saved tab order, active tab, and inactive draft
restoration.
## Validation
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- pre-push `pnpm knip` (passes with the existing flac tag hint)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12269-Fix-V2-draft-lifecycle-persistence-3606d73d365081b4a84feb1696ed88bb)
by [Unito](https://www.unito.io)
---------
Co-authored-by: xmarre <xmarre@users.noreply.github.com>
## Summary
Frontend half of MCP OAuth (BE-638) — `oauth_request_id` plumbing, Cloud
session cookie integration, and the consent screen that cloud's
`/oauth/authorize` hands off to the browser. Scoped strictly to
OAuth/consent code; local cloud-dev support is held back as local-only
changes.
## Changes
- **What**:
- `oauth_request_id` capture + `sessionStorage` preservation across
login / signup / SSO (`onboardingCloudRoutes.ts`,
`preservedQueryNamespaces.ts`, router guards). Cleared on success or
explicit logout. Never forwarded to a third-party redirect.
- `POST /api/auth/session` integration so the Cloud session cookie is
set before the consent fetch (`useSessionCookie.ts`).
- Consent route `/cloud/oauth/consent` — fetches the JSON challenge,
renders client display name + scopes + redirect URI + Native/Web
app-type badge, submits the user's decision.
- Workspace picker: inline radio list (mirrors the cloud workspace
switcher) using `WorkspaceProfilePic` avatars. Capped at `max-h-72` with
`scrollbar-custom` so 10+ workspaces stay discoverable.
- Cloud calls go via relative URLs (same-origin through Vite proxy /
prod reverse proxy) — fixes the cross-origin cookie loss that would
bounce the consent challenge back to login.
- 400 / 403 / 404 cloud errors map to user-facing copy (expired /
scope_broadening / feature_unavailable).
- `vite.config.mts` only adds the `/oauth` proxy (5 lines) — required
for same-origin OAuth calls in dev.
- **Breaking**: None.
- **Dependencies**: None added.
## Review Focus
- **Cross-origin cookie footgun** (`oauthApi.ts:54-67`): chose
same-origin relative URLs deliberately, comment captures why.
- **Deny + workspace_id fallback** (`OAuthConsentView.vue:312-313`): if
user denies without picking a workspace, we send `workspaces[0].id`.
Cloud team should confirm deny is workspace-independent.
- **Login-flow preservation**: confirm no third-party redirect ever sees
`oauth_request_id`.
- **`useSessionCookie` ordering**: session cookie must be set before any
OAuth resume navigation fires.
- **`labelFor()` uses computed i18n keys** (`oauth.workspace.${value}`):
static key analyzer flags `personal`/`team`/`owner`/`member` as unused,
but they're read at runtime.
## Commits
| | Commit | Scope |
|---|---|---|
| 1 | `<foundation>` | OAuth plumbing — `oauth_request_id`, session
cookie, consent route, API client (10 files) |
| 2 | `a973abec0` | UI polish — inline workspace picker, error mapping,
app-type badge, redirect URI (5 files) |
## Test plan
- [x] `pnpm test:unit src/platform/cloud/oauth/
src/platform/auth/session/` — 17/17 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual staging E2E — blocked on cloud-side BE-633–BE-637
## Out of scope (kept as local-only changes)
Local cloud-dev fixes (Firebase auth emulator wiring, local API base,
Mixpanel disable, registry proxy, `__DEV_SERVER_COMFYUI_URL__` build
define) are useful for running the OAuth flow against a local cloud
backend, but aren't required for staging/prod. They're held back from
this PR and can ship separately if needed.
## Supersedes
Closes#12158.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12159-feat-OAuth-consent-UI-for-MCP-authorization-BE-638-35e6d73d3650811e956ff550995f40e6)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
With the dynamic vram changes, vram is both much more difficult to
measure, and much less useful of a metric. To prevent confusion, it has
been removed as a metric.
See also: #9074
Relevant: #9935. A PR claimed to solve the same issue (and was approved
by me), but the issue persists. Even when checking out that exact
commit, the issue does not appear affected.
This PR is somewhat heavier. It converts the outputs into
shallowReactive. Since there is no individual moment of registration for
outputs, this conversion happens on type change and leverages that
calling `shallowReactive` on a shallow reactive is low cost and
reflexive. It also adds a test to ensure that regression can not happen
in the future.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/3e4f4a0a-906f-4539-95b6-b2e80de7ceff"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/1a29ac66-ed5e-4874-82dc-ce9f6135dea5"
/>|
Reduce the value setter to setHostWidgetState(value) only. Previously it
also walked linked input widgets, mutated their widgetValueStore rows,
and fell through to resolved.widget.value = value, causing host A's
edit to leak into interior state visible to host B, and silently
mutating the interior widget that other hosts project from.
Add focused per-host independence tests in promotedWidgetView.test.ts.
Invert the SubgraphWidgetPromotion control case "Vue + fixed" that
explicitly asserted on the leak (now asserts the interior source seed
stays at 0 instead of being pushed to 123456).
Amp-Thread-ID: https://ampcode.com/threads/T-019e4d20-5a4c-70ac-9492-2a5d5be83a2d
Co-authored-by: Amp <amp@ampcode.com>
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
Triggered by workflow run `26260485885`.
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
The helper had no production callers after the previous consolidation —
it only survived because two test files exercised it. Migrate those
tests to the canonical helpers (`reorderSubgraphInputsByWidgetOrder` and
`reorderSubgraphInputsByName`) and drop the dead export.
- promotionUtils.test.ts: removed two tests that duplicate existing
reorder coverage; migrated the remaining outer-link target_slot test
to `reorderSubgraphInputsByName`.
- SubgraphWidgetPromotion.test.ts: dropped the `'atIndex'` discriminant
from the ReorderSpec union; rewrote the two parameterized cases and
two standalone tests to use `reorderSubgraphInputsByName` with the
equivalent target order (`['text_1', 'seed', 'text']`).
Amp-Thread-ID: https://ampcode.com/threads/T-019e4cdc-434e-7659-9e1f-b29e3ad8ac14
Co-authored-by: Amp <amp@ampcode.com>
Consolidate the Parameters-panel drag-and-drop wiring into SectionWidgets,
which now owns the imperative DraggableList lifecycle and emits a typed
`@reorder { fromIndex, toIndex }` event.
TabSubgraphInputs previously passed UI-list indices straight to
reorderSubgraphInputAtIndex, which interprets them as indices into
subgraph.inputs. Because the rendered list is a filtered + augmented view
(filters out non-promoted inputs, appends virtual host widgets), the
indices misaligned whenever any non-promoted input or extra widget was
present — moving the wrong row.
Both Subgraph reorder paths now go through reorderSubgraphInputsByWidgetOrder,
which resolves positions by widget identity, matching the Subgraph Editor.
TabGlobalParameters shares the same SectionWidgets reorder contract.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4cdc-434e-7659-9e1f-b29e3ad8ac14
Co-authored-by: Amp <amp@ampcode.com>
## Summary
- Fixes cloud-nodes search not finding nodes like FaceDetailer
- The `/api/object_info` endpoint only returns a subset of nodes per
pack (~39 for Impact Pack), but the registry API has the full list (~197
nodes)
- Now fetches complete node list from registry API while still using
object_info to determine which packs are cloud-supported
## Changes
- Add `fetchRegistryPacksWithNodes()` to fetch full node list from
registry (`/nodes/{packId}/versions/{version}/comfy-nodes`)
- Keep using object_info to determine which packs are cloud-supported
- Prefer registry nodes when available, fall back to object_info nodes
- Add retry logic for comfy-nodes fetching
- Add comprehensive tests (13 new tests, 36 total)
## Test plan
- [x] All existing cloudNodes tests pass (36 tests)
- [x] New tests cover registry node fetching, pagination, retry logic
- [x] Type check passes
- [x] Lint passes
- [ ] Verify search for "FaceDetailer" returns Impact Pack on deployed
preview
## Related
- Fixes failing test in #12388 (the data refresh PR)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Per ADR_0009, linking to a SubgraphInput must be indistinguishable from
a regular link. buildSlotMetadata derived 'linked' via getNodeById on
the link origin, which returns null for the SUBGRAPH_INPUT_ID sentinel
(SubgraphInputNode is not in _nodes_by_id). Widgets stayed enabled and
no slot dot rendered in Vue mode.
Use input.link != null directly, matching the origin-agnostic check in
LGraphNode.updateComputedDisabled used by canvas mode.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4caf-85c8-75d8-86c2-5da97285a81a
Co-authored-by: Amp <amp@ampcode.com>
Regression test ensuring that after `promoteValueWidgetViaSubgraphInput`
creates the SubgraphInput-to-slot link, `updateComputedDisabled()` flips
the interior widget's `computedDisabled` to true.
This is the input-side guarantee that the `BaseDOMWidgetImpl.isVisible()`
fix in the previous commit relies on to hide a promoted DOM widget so
the link connection dot becomes visible.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4c28-5668-7045-b744-da018fd06d25
Co-authored-by: Amp <amp@ampcode.com>
`BaseDOMWidgetImpl.isVisible()` only considered `hidden` and
`LGraphNode.isWidgetVisible`. When a widget's input slot was linked
(e.g. promoted to a SubgraphInput), the canvas dimmed the widget via
`computedDisabled` but the DOM overlay (multiline text, custom
component widgets, etc.) stayed fully rendered on top, hiding the
input connection dot beneath it.
`isVisible()` now also returns false when `computedDisabled` is true,
matching the canvas's intent and exposing the connection dot.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4c28-5668-7045-b744-da018fd06d25
Co-authored-by: Amp <amp@ampcode.com>
Two related bugs in the ADR 0009 promotion path both stem from confusing
a nested PromotedWidgetView's deep `sourceWidgetName` with its immediate
boundary slot name.
- `getWidgetName` no longer peels back through `PromotedWidgetView`. It now
returns `w.name`, which already resolves to the immediate disambiguated
slot name (e.g. `text_1`) via the view's identity getter. Fixes the
context-menu predicate showing "Promote Widget" for a widget that is
already promoted when base names collide.
- `pruneDisconnected` treats a `SubgraphInput` as alive whenever any of
its links still resolves to an interior target slot that has a widget.
This stops the Subgraph Editor's onMount prune from wiping nested
promotions whose `widget.sourceNodeId` points at a deeply-nested
interior node not directly present in the host subgraph.
Both behaviours are covered by new regression tests in
`promotionUtils.test.ts` (`disambiguated nested promotion identity`).
Amp-Thread-ID: https://ampcode.com/threads/T-019e4c28-5668-7045-b744-da018fd06d25
Co-authored-by: Amp <amp@ampcode.com>
## Summary
When adding a node from the library sidebar, the node was not correctly
selected upon placing it. This was due to the canvas capturing the node
under the cursor on mouse down, however the node had not yet been
comitted to the graph at that point, and so selection was then cleared
on mouse up.
## Changes
- **What**:
- add `blockCommitPointerDown` so if the cursor is over the canvas stop
propagation to prevent LiteGraph adding the mouse handler to clear the
selection
## Review Focus
Alternative approaches considered were blocking the event in endDrag
however this then required manual cleanup of LiteGraph handlers or
overriding the `pointer.onClick` function to force selection of our
node, both felt worse than this approach.
## Screenshots (if applicable)
https://github.com/user-attachments/assets/a2eb154e-5178-4a1e-b5c7-884efd7a10c6
When an app mode workflow is opened on fresh page load, either from a
template url, or a persisted in browser cache, the UI would briefly
display the graph view prior to swapping to app mode. This is fixed by
continuing to display the splash screen until workflow state has loaded.
Share by url brings unique difficulties. The function call does not
return until a user has responded to a dialogue. If the splash screen
were blocked by this, the user would never be able to see the dialogue.
Consequentially, this change is not applied to shared workflow urls and
the (very unlikely) url including both a template url and a share url
will now prioritize the template url.
A best effort e2e test is included, but is a little clunky.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12387-Persist-splash-until-graph-load-completes-3666d73d3650813495e4ccad6052c1e4)
by [Unito](https://www.unito.io)
- reorderSubgraphInputs: validate orderedIndices is a permutation of 0..n-1
before mutating subgraph.inputs/subgraphNode.inputs; log and bail on
invalid input so slots aren't silently dropped or duplicated.
- _hydratePreviewExposures: treat an explicit `[]` on properties as the
user-cleared value instead of falling back to the legacy locator-keyed
store entry; only run the legacy migration when the property is truly
absent or not an array.
- Add round-trip test that serializes -> seeds a legacy locator entry ->
reconfigures and asserts the explicit empty array is preserved.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4911-134e-708d-a511-fb356b87224f
Co-authored-by: Amp <amp@ampcode.com>
## Summary
- Spark 2.x requires SparkRenderer in scene tree; add it in SceneManager
and protect it in clearModel so model reloads don't dispose the splat
renderer.
- three 0.184 OrbitControls listens on ownerDocument; drop redundant
pointermove/up .stop in Load3D containers so the document listener can
receive events.
- Narrow Texture.image type for 0.184 strict typing.
## Summary
Allow asset/media FormDropdown searches to select the top filtered
result when the user presses Enter. This covers image, video, audio,
mesh, model-like asset selects, and other `WidgetSelectDropdown`-backed
media widgets.
## Implementation Scope
This PR implements a **top-result Enter shortcut** for the custom
asset/media dropdown path only:
- In scope: `WidgetSelectDropdown` -> `FormDropdown` asset/media
widgets.
- In scope: while the dropdown is open, single-select, and the search
text is non-empty, the first current search result becomes the Enter
candidate.
- In scope: pressing Enter in the search input selects that
candidate/top result through the existing selection path.
- In scope: candidate feedback for this shortcut, including visual
candidate styling and a polite screen-reader announcement for the
current top result.
- In scope: stale async search protection, empty-query/no-result no-op
behavior, multi-select guard behavior, and focus return to the trigger
after Enter selection closes the menu.
- Out of scope: plain combo widgets (`WidgetSelectDefault` /
`SelectPlus`). That path is PrimeVue-based and should be handled
separately from this focused asset-widget PR.
- Out of scope: full combobox/listbox keyboard navigation, including
Tab-to-list focus, ArrowUp/ArrowDown candidate movement, Home/End
behavior, scroll-to-active-item behavior, and a full ARIA
combobox/listbox refactor.
Follow-up arrow-key navigation should validate the interaction model
separately. This PR keeps the candidate state narrow and localized so
that future work can either extend it into movable active-item state or
replace it as part of a fuller combobox/listbox implementation.
## Changes
- **What**: Added an explicit Enter event from `FormSearchInput`, routed
it through the FormDropdown menu actions, and selected the current top
search result in `FormDropdown`.
- **What**: Kept the existing `computedAsync` + debounced filtering path
for normal typing, while Enter performs a one-off search against the
latest input before selecting. Stale async Enter results are ignored if
the query or item source changes before resolution.
- **What**: Prevented closed FormDropdown state from treating the full
unfiltered list as current search results, limited Enter-to-select to
single-select dropdowns, and made empty search Enter a no-op.
- **What**: Returned focus to the dropdown trigger after single-select
selection closes the menu.
- **What**: Added candidate styling for the first current FormDropdown
result while a search query is active so the Enter target is visible to
users.
- **What**: Added a polite screen-reader announcement for the current
top result candidate.
- **What**: Fixed the FormDropdownMenuActions `baseModelSelected` model
default to use a `Set` factory instead of a shared instance.
- **What**: Added unit coverage for the search Enter event, FormDropdown
selection behavior, focus return, debounce/Enter behavior, stale async
Enter protection, empty-query no-op behavior, closed-state stale result
protection, multi-select guard behavior, and candidate announcement
behavior. Added App Mode E2E coverage for asset FormDropdown Enter
selection.
- **What**: Extracted reusable app-mode dropdown fixture helpers and
updated the existing FormDropdown clipping test to use the shared
helper.
## Review Focus
Please focus review on the asset/media FormDropdown path, especially
`getTopSearchResult()`, the single-select/empty-query guards, stale
async search protection, trigger focus return after selection, and
candidate feedback in grid/list layouts.
The plain combo path and full arrow-key navigation are intentionally
left for separate follow-up work.
## Screenshots (if applicable)
https://github.com/user-attachments/assets/3eb3456d-93a3-4959-91a3-188f8116ccc9
Validation performed:
- Latest final-commit validation:
- `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts`
- Commit hook: `pnpm exec stylelint ...`, `pnpm exec oxfmt --write ...`,
`pnpm exec oxlint --type-aware --fix ...`, `pnpm exec eslint --cache
--fix ...`, `pnpm typecheck`
- Push hook: `pnpm knip --cache`
- `git diff --check`
- Earlier branch validation for this flow:
- `pnpm install`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "Drag and
Drop|FormDropdown search Enter selects the top filtered item"
--reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appMode.spec.ts -g "FormDropdown
search Enter selects the top filtered item" --reporter=list`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm test:browser --
--project=chromium browser_tests/tests/appModeDropdownClipping.spec.ts
-g "FormDropdown popup is not clipped" --reporter=list`
## Summary
Migrate pnpm configuration to the v11 layout and clean up stale v10-era
references.
## Changes
- **What**: Moves pnpm settings into `pnpm-workspace.yaml`, converts
build dependency policy to `allowBuilds`, removes stale workspace
`packageManager` pins, and updates global install commands in CI.
- **Dependencies**: No new dependencies.
## Review Focus
- Confirm pnpm v11 workspace settings match the former `.npmrc`
behavior.
- Confirm CI global install syntax is compatible with pnpm v11.
## Test Plan
- `pnpm install --frozen-lockfile`
- `pnpm exec oxfmt --check pnpm-workspace.yaml
packages/shared-frontend-utils/package.json
packages/registry-types/package.json packages/ingest-types/package.json
packages/design-system/package.json
.github/workflows/weekly-docs-check.yaml
.github/workflows/pr-claude-review.yaml`
- commit hook: `pnpm typecheck`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12195-build-migrate-pnpm-config-to-v11-35e6d73d36508116a821dbc71db94cd1)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Amp <amp@ampcode.com>
## Summary
Adds a focused FE-130 assets sidebar browser-test slice without
extending the stateful asset helper path.
## Changes
- **What**: Extends `jobsRouteFixture` with job-detail and
history-delete helpers for generated asset flows.
- **What**: Adds an assets sidebar tab spec covering generated/imported
rendering, preview opening, generated selection footer actions, and
explicit delete refresh behavior.
## Review Focus
The delete test keeps backend state explicit: it captures the
`/api/history` request, then replaces the `/api/jobs` history mock with
the post-delete response. The imported-file and `/api/view` mocks stay
local to this focused spec instead of growing `AssetHelper` or adding a
sidebar-specific fixture.
## Summary
Add entries to `MODEL_NODE_MAPPINGS` so the model browser's "Use" button
creates the correct loader node for two model directories introduced in
recent node-pack updates.
## Changes
- **What**: 2 new entries in
`src/platform/assets/mappings/modelNodeMappings.ts`:
- `geometry_estimation` → `LoadMoGeModel` / `model_name`
- `optical_flow` → `OpticalFlowLoader` / `model_name`
- **Breaking**: none
## Review Focus
- Node class names and input keys cross-checked against the published
node definitions:
- `LoadMoGeModel` is the MoGe geometry-estimation loader (companion
nodes: `MoGeInference`, `MoGeRender`, `MoGeContextStrandModel`)
- `OpticalFlowLoader` is the RAFT optical-flow loader
- Both directories accept a single model file via a COMBO widget on the
loader node
## Test plan
- [ ] Verify "Use" button works for each new model directory in the
model browser:
- One `geometry_estimation` model (e.g.
`moge_2_vitl_normal_fp16.safetensors`) → creates a `LoadMoGeModel` node
with the file preselected
- One `optical_flow` model → creates an `OpticalFlowLoader` node with
the file preselected
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12389-feat-add-model-to-node-mappings-for-geometry_estimation-and-optical_flow-3666d73d36508190981fcaf77f9d2ee4)
by [Unito](https://www.unito.io)
Auto-exposed previews would reappear after reload even when the user had
explicitly hidden them. The host's `properties.previewExposures` already
captures user intent, but `LGraph.configure` was unconditionally running
`autoExposeKnownPreviewNodes` after hydration, second-guessing the
serialized state.
- Gate `autoExposeKnownPreviewNodes` on `properties.previewExposures`
being undefined. A node that has never been saved (or comes from a
pre-PR workflow with no `previewExposures` property) still gets the
convenience auto-expose pass; once the property exists in any form,
including an empty array, the serialized state is authoritative.
- Always serialize `previewExposures` (including as `[]` when empty)
so "user cleared everything" is distinguishable from "never touched".
Amp-Thread-ID: https://ampcode.com/threads/T-019e4671-bc67-7039-b6c6-9f5b11a07e42
Co-authored-by: Amp <amp@ampcode.com>
Previously, the "Hide all" action in the subgraph editor skipped any
promoted widget that was a linked promotion, making the button a no-op
for the most common kind of promotion. Drop the isLinkedPromotion skip
so "Hide all" actually hides everything.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4671-bc67-7039-b6c6-9f5b11a07e42
Co-authored-by: Amp <amp@ampcode.com>
Extract reorderSubgraphInputs into subgraphUtils as the single primitive
that keeps all slot/link bookkeeping in sync (inner origin_slot, outer
target_slot, inputs-reordered event). applySubgraphInputOrder now only
owns the promotion-specific widget-value preservation.
Amp-Thread-ID: https://ampcode.com/threads/T-019e4671-bc67-7039-b6c6-9f5b11a07e42
Co-authored-by: Amp <amp@ampcode.com>
Fixes 3 different bugs when making links to and from subgraph IO from
vue nodes
- When dragging a link from a node to a subgraph IO, there is no
feedback if a slot is not a valid connection target or if a slot is
actively hovered
- When a link is made from a subgraph IO to a node, the reactivity is
not triggered on the node to indicate a change of link state.
- When dragging a link from a subgraph IO to a node, the link would not
snap to the valid connection targets on nodes
- The fix for this one is not as thorough as I would like. It only
allows connections to the slot, not connections to the hovered widget.
We have two deeply disconnected linking systems and properly reconciling
them would be a multi-week project.
Resolves FE-561
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12281-Subgraph-io-fixes-3606d73d365081089f7ef19331c6d70a)
by [Unito](https://www.unito.io)
## Summary
Allow staging api/platform base URLs to be overridden by env vars so
non-cloud builds can target an alternate backend without source edits.
## Changes
- **What**: `BUILD_TIME_API_BASE_URL` / `BUILD_TIME_PLATFORM_BASE_URL`
in `src/config/comfyApi.ts` now read
`import.meta.env.VITE_STAGING_API_BASE_URL` /
`VITE_STAGING_PLATFORM_BASE_URL` first, falling back to the existing
`stagingapi.comfy.org` / `stagingplatform.comfy.org` constants. Vars
typed in `src/vite-env.d.ts` and documented in `.env_example`.
- **Breaking**: None. Defaults unchanged. The cloud-runtime override
path via the features endpoint (`comfy_api_base_url`,
`comfy_platform_base_url` in `RemoteConfig`) is untouched.
## Review Focus
Override only applies to the non-prod branch of the build-time ternary,
so prod builds (`USE_PROD_CONFIG=true`) cannot be redirected. Cloud
builds continue to resolve URLs at runtime via `remoteConfig` regardless
of these env vars.
## Note
Pre-commit `pnpm typecheck` fails on `origin/main` independently of this
change (`src/utils/nodeDefUtil.ts` and
`src/workbench/utils/nodeHelpUtil.ts` import non-existent exports from
`@/schemas/nodeDefSchema` / `@/types/nodeSource`). Verified by stashing
this PR's diff and re-running. Committed with `--no-verify`; please
address the underlying breakage separately.
This is a targeted small scope change to improve the availability for
converting group nodes into a subgraph.
The prior implementation would only apply on the litegraph context menu
option for converting a node to a subgraph. It failed to apply on any of
the other more common methods. The code for unpacking group nodes has
been moved directly into the setup for converting a group of nodes into
a subgraph and drastically simplified.
Of note, several other long lived bugs were found while working on this
fix, but they are out of scope for this targeted PR.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12356-On-subgraph-conversion-always-unpack-group-nodes-3666d73d365081d09774c00a851b8198)
by [Unito](https://www.unito.io)
- Skip value-control on promoted widgets when host input is externally
linked (parity with src/scripts/widgets.ts), preventing randomize/
increment from overwriting upstream-driven seeds each queue
- Refresh useResolvedSelectedInputs on convert-to-subgraph and
subgraph-created root-graph events, fixing stale node resolution
in AppBuilder after graph restructure
- Remove forbidden `as unknown as IBaseWidget` cast in SubgraphEditor
by replacing the fake widget object with a local discriminated union
(ActiveRow = PromotedRow | PreviewRow); preview demote without a
real source widget now removes the exposure directly
- Use node.serialize() instead of full graph.serialize() in the
promoted widgets test helper used in tight polls
Tests added for each behavior change.
Amp-Thread-ID: https://ampcode.com/threads/T-019e3eb1-878a-77ee-8621-763ba6f1b10b
Co-authored-by: Amp <amp@ampcode.com>
Replace the SUBGRAPH_INPUT_ID sentinel check in buildSlotMetadata with a
check that the upstream node actually resolves via getNodeById. Promoted
widgets (link origin = SUBGRAPH_INPUT sentinel) naturally render as
editable instead of disabled with a phantom upstream chip, and the
renderer no longer imports a litegraph sentinel constant.
Test setup now uses a real upstream node + upstream.connect() instead of
an unreferenced input.link id. Added regression test for the
non-resolvable-upstream case.
Amp-Thread-ID: https://ampcode.com/threads/T-019e3d8c-b6b4-731e-b3fb-9bdb6fefb1ae
Co-authored-by: Amp <amp@ampcode.com>
Promoted-widget path was silently dropping combo filter forwarding
and isPartialExecution gating that the legacy path applied. Extract a
single production entry point - nextValueForLinkedTarget - that both
call sites use, with required isPartialExecution/nodeId context.
- Guard non-array combo value sources in computeNextComboValue
- Forward control_filter_list through the promoted path
- Skip promoted control cycling during partial executions
- Preserve deliberate BEFORE/AFTER divergence per ADR 0009 (source
widgets behind subgraph inputs are inert for prompt serialization)
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e3cc6-7c36-7434-bb30-19d951a58e4a
PromotedWidgetView.applyValueControlToHost duplicated the
numeric increment/decrement/randomize logic from widgets.ts's
applyWidgetControl, with two drifts:
- combo widgets had no control-after-generate support on
promoted hosts (only number widgets worked)
- the final clamp used raw min/max instead of the JS-safe
bounded versions
Extract into src/scripts/valueControl.ts:
- isValueControlWidget (single detector for the
IS_CONTROL_WIDGET symbol + lifecycle hooks)
- computeNextControlledValue (pure helper handling combo
filter/wrap/index and number step2/range/clamp paths)
- ValueControlMode type
Both call sites now share the algorithm. Promoted hosts
gain combo-widget control support automatically and pick
up the safe-bound clamp fix.
Net -100 LOC, removes all @ts-expect-error lines from the
old applyWidgetControl body.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2aa7-5cbb-71ff-ae42-52e5e680cd37
Co-authored-by: Amp <amp@ampcode.com>
CI shard 7 deterministically failed the nested-pack-promoted-values test
with steps=0 instead of 8 on the host SubgraphNode. Locally the same
host node renders steps=8.
Root cause: ensureHostWidgetState() seeds the host store entry from the
fallback chain on first render. In CI the first Vue render landed before
the subgraph interior nodes had finished applying their serialized
widgets_values, so the host entry latched onto a stale interior default
(0) and the value getter — which prefers host state — kept returning 0
forever. The interior eventually became 8 but nothing re-pushed it into
the host entry.
Track the last value we auto-seeded on the view instance. On subsequent
ensureHostWidgetState() calls (each Vue render), if the host entry still
holds that auto-seed value the user has not edited it, so re-resolve via
the fallback chain (linked interior state -> deepest widget state ->
live interior value, ignoring host state) and update the host entry if
the fresh value differs. Any user/migration write through
setHostWidgetState clears the marker, so per-host divergence (e.g.
seed-after-generate increment) keeps winning over the shared interior.
Amp-Thread-ID: https://ampcode.com/threads/T-019e298f-55f7-722a-be87-e4fef79ede3a
Co-authored-by: Amp <amp@ampcode.com>
This reverts commit e6c4f6e31b.
The CI nested-subgraph promoted-widget persistence test fails
deterministically with steps showing 0 instead of 8 and
control_after_generate quarantined. Local runs pass, so reverting
the consolidation pass to verify it's the cause and ship the
underlying behavior change in a separate, smaller PR.
Amp-Thread-ID: https://ampcode.com/threads/T-019e298f-55f7-722a-be87-e4fef79ede3a
Co-authored-by: Amp <amp@ampcode.com>
Reverts two SubgraphNode cleanups from e6c4f6e31 that were assumed
redundant but appear to cause CI-only flake on the nested-subgraph
promoted widget persistence test (steps shows 0 instead of 8 and
control_after_generate is missing entirely on host node 57).
Restores:
- onAdded() override calling invalidatePromotedViews so the cache is
invalidated after the node joins the parent graph, not only at
construction.
- _linkedEntriesCache + cache parameter on _getLinkedPromotionEntries
so views see a coherent set of entries during configure ordering.
Amp-Thread-ID: https://ampcode.com/threads/T-019e298f-55f7-722a-be87-e4fef79ede3a
Co-authored-by: Amp <amp@ampcode.com>
The isLoadingGraph guard added in f090ea3d2 (#11422) early-returned the
raw legacy tuples, bypassing upgradeAndValidateInput. Persisted/test-
injected legacy [nodeId, name] tuples then stayed uncanonicalized and
were filtered out by useResolvedSelectedInputs's isWidgetEntityId guard,
leaving the App Mode widget list empty.
LGraph.configure() populates all nodes before dispatching configured
(LGraph.ts dispatch in finally), so input upgrade can safely run during
loading. Only output pruning is deferred to preserve the original PR's
intent of not silently dropping transiently-missing nodes.
Fixes the CI E2E failures on appMode.spec.ts, appModeWidgetValues.spec.ts,
and appModeDropdownClipping.spec.ts.
Amp-Thread-ID: https://ampcode.com/threads/T-019e298f-55f7-722a-be87-e4fef79ede3a
Co-authored-by: Amp <amp@ampcode.com>
*PR Created by the Glary-Bot Agent*
---
Codifies the axiomatic demotion behavior discussed in [the SLOP
thread](https://comfy-organization.slack.com/archives/C0AQ646ELUR/p1778740323454569)
on top of #12197.
## Axiom
External links into a host subgraph slot are independent of the
promotion projection. Demote retracts the projection only; the
`SubgraphInput` and any inbound link outlive it.
## Change
In `demoteWidget`, branch on `hostInput.link != null`:
- **External link present** → call `linkedInput.disconnect()`. Removes
the interior link (SubgraphInput → interior widget), which fires
`input-disconnected` and clears `_widget` via the existing handler. The
host slot and its external link survive.
- **No external link** → existing `removeInput` path, so demote remains
the inverse of promote in the common case.
## Why `disconnect()` and not just `ensureWidgetRemoved()`
`_resolveLinkedPromotionBySubgraphInput` falls back to the interior link
when `_widget` is missing — so clearing `_widget` alone lets the
projection re-materialize in `host.widgets` on the next render.
Disconnecting the interior link is the only way to prevent re-resolution
without inventing a new instance property.
## Out of scope
- Slot reuse on re-promote (creates a new slot today; orphaned slot
stays for the external link). Deferred — discussed in the thread.
- Pre-existing review findings in #12197 unrelated to this branch
(preview placeholder rows, `pruneDisconnected` not pruning preview
exposures).
## Tests
- `demoteWidget` retract path: 2 new unit tests in
`promotionUtils.test.ts`
- With external link: slot survives, `host.widgets` no longer contains
the projection
- Without external link: slot is removed (existing behavior preserved)
- `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec vitest run
src/core/graph/subgraph/promotionUtils.test.ts` all clean (37/37)
Refs: ADR 0009.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12278-fix-subgraph-preserve-host-slot-and-external-link-on-demote-axiomatic-projection-retr-3606d73d365081ae97ace414c6e307f0)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Cleanup pass surfaced by the PR-12197 audit. Net -73 lines.
promotionUtils + migration:
- Export findHostInputForPromotion; replace duplicate
findHostInputForLinkedSource in proxyWidgetMigration.
- Merge isPromotedOnParent into isWidgetPromotedOnSubgraphNode via
optional widget? param.
- Inline single-line getSourceNodeId at its three call sites.
- Collapse findSourceWidget + resolveSourceWidget into one resolver
with always-on promotable fallback.
- Fold legacyProxyWidgetNormalization into migration/; port its tests.
SubgraphNode + view:
- Drop unused cache=true parameter from _getLinkedPromotionEntries.
- Remove redundant _linkedEntriesCache (views cache transitively).
- Remove redundant onAdded override (constructor already invalidates).
- Remove empty beforeQueued stub on PromotedWidgetView.
- Inline single-use findWidgetByIdentity in
resolveConcretePromotedWidget.
Schemas:
- Extract parseNodePropertyArray helper; the three parseX functions
become wrappers.
Stores + services:
- Extract buildImageUrls helper in nodeOutputStore; both
getNodeImageUrls{,ByExecutionId} delegate to it.
- Move exposure→promotion-shape mapping into previewExposureStore as
getExposuresAsPromotionShape; litegraphService calls the selector.
Browser tests:
- Add getAllHostPromotedWidgets util that loops over hosts calling the
canonical getPromotedWidgets; SubgraphHelper.getHostPromotedTupleSnapshot
becomes a 1-line delegate.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e28cb-6f6c-7209-a0c4-909dfb139829
- Drop mock-only WidgetActions demote test; e2e covers the real path
- Drop redundant negative paste-migration test
- Rewrite the two paste-time hook-wiring tests in LGraphCanvas.clipboard.test.ts
to wire the real flushProxyWidgetMigration / autoExposeKnownPreviewNodes
helpers and assert observable post-paste state (proxyWidgets cleared,
PreviewExposureStore exposures populated) instead of pinning hook
invocations on static mocks.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
Adds a new `inputs-reordered` event to SubgraphEventMap, dispatched from
the single `applySubgraphInputOrder` funnel after invalidatePromotedViews,
the link `origin_slot` reindex, and the value-restore pass — so listeners
see fully consistent state. No-op reorders (where the order didn't actually
change) are suppressed.
SubgraphEditor.vue subscribes to the new event alongside its existing
widget-promoted/widget-demoted/input-added/removing-input listeners and
drops the imperative `refreshPromotedWidgets()` it had been calling after
`reorderSubgraphInputsByWidgetOrder` to compensate for the missing event.
Behavioral coverage is pinned by the existing SubgraphEditor.test.ts
"updates rendered order when promoted widgets are reordered" test — if the
new dispatch were missing, the snapshot wouldn't refresh and the rendered
order assertion would fail.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
SubgraphEditor.vue:
- Replace `inputOrderVersion = ref(0)` manual bump with a `shallowRef`
snapshot of `activeNode.widgets`, refreshed on the relevant subgraph
events (widget-promoted/widget-demoted/input-added/removing-input)
and on `activeNode` changes.
- Fixes a latent bug where any external promote/demote/input-added/
removing-input event left the sidebar list stale until the next
selection change.
- Local reorder still calls refreshPromotedWidgets() because
reorderSubgraphInputsByWidgetOrder doesn't dispatch a dedicated event
yet; can drop in favor of the listener once that lands upstream.
- Add SubgraphEditor.test.ts regression: reverse promoted widget order
via the DraggableList v-model setter and assert the rendered order
updates without a manual bump.
widgetValueIO.ts + widgetValueStore.ts:
- Add `setValue(graphId, nodeId, name, value)` action to
useWidgetValueStore as the single legitimate writer for registered
widget state.
- Route writeWidgetValue() through the new action instead of mutating
state.value directly. The boundary helper no longer writes through
its own boundary.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
- promotedWidgetView.ts: seed ensureHostWidgetState from this.value (full
effective resolution chain) instead of resolveAtHost(), so a restored
promoted value isn't shadowed on first render
- LGraphNode.ts: add JSDoc to clone() documenting the borrowed source-id
window between clone() and graph.add() (replaces former -1 sentinel)
- appModeStore.test.ts: add suite-level afterEach(restoreAllMocks) so a
failing assertion can't leak per-test console.warn spies
- AppBuilder.vue: prefer entry.displayName over opaque entry.entityId for
resolved-input title fallback; mirrors symmetric branch on :240
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
- Extract canonical isWidgetValue helper to types/widgets.ts and dedupe
copies in promotedWidgetView, promotionUtils, and SubgraphNode
- Replace cast in proxyWidgetMigration.pickHostValue with isWidgetValue
narrowing; treat invalid host payloads as holes
- Replace 'getSlotFromWidget' duck-type guard in promoteWidget with
instanceof LGraphNode narrowing
- Add DEV-only console.warn in SubgraphNode.serialize() when host widgets
contain non-promoted entries (per ADR 0009)
- Extract LGraphTriggerActions tuple as single source of truth for the
runtime Set and the LGraphTriggerAction type union
- Replace in-place tuple slot mutation in appModeStore.updateInputConfig
with splice replacing the whole entry
- Drop dead setter on activeWidgets computed in SubgraphEditor.vue
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
- proxyWidgetMigration: switch repairValue/migratePreview to exhaustive
switches with a shared `assertUnreachablePlan(plan: never)` helper so
adding a new Plan kind triggers a TS error at the dispatcher.
- previewExposureStore: drop unused `moveExposure` action (and its
tests); document deferred Object.freeze hardening at getExposures.
- usePromotedPreviews: drop optional chaining on
nodeOutputStore.getNode*ByExecutionId methods (always defined).
- BaseWidget: drop the unused `_suppressPromotedOutline` parameter on
`getOutlineColor` and the `suppressPromotedOutline` field on
`DrawWidgetOptions`; remove all 14 call sites.
- SubgraphNodeWidget.vue: switch to tuple-form `defineEmits<{ ... }>()`.
- LGraph: rewrite the `proxyWidgetMigrationFlush` JSDoc to cite the
real reason for late binding (the migration module's transitive
workbench imports, not PreviewExposureStore); mark both
`proxyWidgetMigrationFlush` and `autoExposePreviewNodes` `@internal`.
- proxyWidgetMigration tests: update assertions to verify behavior
(host inputs, exposures, quarantine entries) now that
`flushProxyWidgetMigration` returns void.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a
Co-authored-by: Amp <amp@ampcode.com>
Adds a no-restricted-paths zone enforcing that src/world/ cannot import
from src/lib/litegraph/. The world layer owns canonical entity identity
(WidgetEntityId, value store I/O) and must not depend on litegraph
types or values; this rule prevents accidental coupling.
Two existing imports in entityIds.ts (`NodeId`, `UUID`) are temporarily
suppressed with eslint-disable-next-line + TODO comments:
- NodeId will become a branded EntityId owned by src/world/.
- UUID is a primitive string brand and should move to src/utils/.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2561-ca22-76fa-8356-0c6f92548f8d
Co-authored-by: Amp <amp@ampcode.com>
The appModeStore identity refactor required every selected input to map
to a `widget.entityId`, set by `BaseWidget.setNodeId`. Third-party
extensions that push plain POJO widgets directly onto `node.widgets`
(bypassing `addCustomWidget`) have no `entityId`, so their selections
were dropped from App Mode with a "no canonical identity available"
warning and the linear-widgets panel rendered empty.
Add `getWidgetEntityIdForNode(node, widget)` in `litegraphUtil` that
returns `widget.entityId` when present and otherwise derives
`(rootGraphId, node.id, widget.name)` directly. Apply the helper in:
- `safeWidgetMapper` so the Vue projection includes POJOs
- `appModeStore.findWidgetByEntityId` and the legacy-NodeId branch so
POJO selections resolve on first selection and on reload
Fixes the `@vue-nodes In App Mode, widget width updates with panel size`
browser test for nodes that use the legacy `node.widgets.push(...)`
pattern.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2561-ca22-76fa-8356-0c6f92548f8d
Co-authored-by: Amp <amp@ampcode.com>
`_deserializeItems` previously bypassed both the ADR 0009 proxyWidget
migration and preview-exposure hydration for pasted SubgraphNodes, so
older clipboard data lost promoted widgets and preview exposures.
- Run `proxyWidgetMigrationFlush` for every top-level pasted SubgraphNode
carrying legacy `properties.proxyWidgets` (nested hosts already covered
by `LGraph.configure`).
- Topologically sort `parsed.subgraphs` before configuring so nested
SubgraphNodes' source-resolution sees a fully configured interior.
- Add `remapPreviewExposures` in `remapClipboardSubgraphNodeIds` to
patch `properties.previewExposures[].sourceNodeId` alongside
`proxyWidgets` after collision-driven interior id remap.
- Extract `autoExposeKnownPreviewNodes` from `promoteRecommendedWidgets`
and expose via a new late-bound `LGraph.autoExposePreviewNodes` hook,
invoked from both `LGraph.configure` and `_deserializeItems` so older
data without `properties.previewExposures` still derives canvas-image
previews from known interior node types.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2486-6c35-720c-b983-82df2eda9c7a
Co-authored-by: Amp <amp@ampcode.com>
- Skip LivePreview render on SubgraphNode hosts; previews flow through
promotedPreviews / previewExposureStore as the single source of truth
- Treat exposures as authoritative in SubgraphEditor.getActivePreviewWidgets
so $$canvas-image-preview rows always surface, even when the interior
source node lacks a matching promotable widget
- Hydrate canonical/legacy preview exposures at configure time in
SubgraphNode._hydratePreviewExposures; usePromotedPreviews is now a
pure read with path-style nested host fallback
- Add KSampler/KSamplerAdvanced to virtual canvas-image-preview node types
so live samplers expose preview rows pre-execution
- SubgraphEditor.open() returns early when panel is already visible to
avoid flaky context-menu opens
- Remove obsolete proxy-widget test; convert inline @vue-nodes name
prefix to tag annotation across subgraphPromotion.spec.ts
- Drop stale "migrates legacy host exposure keys while reading previews"
unit tests now that migration runs at configure time
Review feedback fixes:
- useResolvedSelectedInputs: clone graphNodes array to avoid shared
reference with app.rootGraph.nodes
- previewExposureSchema: avoid mutating the property parameter
- nodeOutputStore: case-insensitive `.svg` check
Amp-Thread-ID: https://ampcode.com/threads/T-019e23c9-3dc1-7381-ae59-c0372b3325e2
Co-authored-by: Amp <amp@ampcode.com>
The candidate filter compared `toKey()` outputs across the active
section (PromotedWidgetView, key includes `:sourceNodeId` suffix) and
the interior section (raw widget, no suffix). The shapes never matched,
so every link-promoted widget kept showing in the Hidden section as
`icon-eye/eye-off`. Compare on the interior `(node.id, widget.name)`
identity instead.
Update the Properties panel test to demote a link-promoted widget via
the source-node context menu (since linked promotions render
`icon-link` and disable the editor toggle by design).
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
The unknown-status branch of the input-select sidebar was binding
the title to the encoded entityId (e.g. graphId:nodeId:name) instead
of the persisted displayName. Users (and the appModePruning test)
saw an unreadable id where the original widget name should appear.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
AppInput's togglePromotion was pushing a bare nodeId tuple into
selectedInputs. The new useResolvedSelectedInputs projection drops
non-canonical ids, so clicks in App Builder never produced sidebar
entries. Forward widget.entityId through useProcessedWidgets and
NodeWidgets so AppInput can persist the canonical WidgetEntityId.
upgradeAndValidateInput also pruned any entityId whose widget was
missing, even when the host node still existed. Restore the
node-exists fallback so dynamic widgets surface as 'Widget not visible'
rows instead of being silently dropped on load.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
Introduce `useResolvedSelectedInputs` that maps each persisted
`[entityId, displayName, config?]` entry to a discriminated union of
`{ status: 'resolved', node, widget, ... } | { status: 'unknown', ... }`.
Handlers (rename, remove, resize, bounding) now receive widget instances
directly instead of re-walking `rootGraph.nodes` per interaction.
- Delete `src/world/widgetLookup.ts`; the lone remaining caller
(`upgradeAndValidateInput` in `appModeStore.ts`) inlines the lookup as a
module-private function.
- `inlineRenameInput` deleted; the template calls `renameWidget(widget,
node, $event)` directly with the in-scope widget.
- `getWidgetBounding` accepts a `ResolvedSelection`; returns `undefined`
for unresolved entries.
- `useAppModeWidgetResizing.onPointerDown` and `updateInputConfig` accept
a widget instance, matching the existing `removeSelectedInput` shape.
- Persisted `selectedInputs` shape unchanged.
- Unresolved entries continue to render as a removable "unknown widget"
pill in the sidebar so users can clean up dangling selections.
Slot-rename → entityId-drift bug investigated and confirmed not present:
the `renaming-input` handler in `SubgraphNode.ts` mutates `widget.label`
and `input.label`, never `widget.name`, so `entityId` is stable across
renames. Documented as an invariant in `useResolvedSelectedInputs`.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
Drop the local `getHostStateName` helper that reconstructed the now-deleted
encoded `[name, sourceNodeId, sourceWidgetName].join(':')` format. Use
`widget.name` directly — the canonical store key after the entityId
migration in c1515374b. Fixes 2 failing tests in
SubgraphWidgetPromotion.test.ts.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
Steer new callers to `widgetValueIO`'s entityId-keyed helpers
(`getWidgetState`, `readWidgetValue`, `ensureWidgetState`). The branded
`WidgetEntityId` makes producer/consumer drift over `(graphId, nodeId, name)`
a type error rather than a silent value mix-up — this was the root cause of
the PR #12197 promoted-widget rendering bugs.
JSDoc-only; no behavior change.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
Replace the encoded `[name, sourceNodeId, sourceWidgetName].join(':')`
host-state-name with the canonical `WidgetEntityId` per ADR 0009. Producers
and consumers flip in the same diff:
- Delete `getPromotedWidgetHostStateName` (was internal disambiguator)
- `PromotedWidgetView` reads/writes via `widgetValueIO` keyed by `entityId`
- `SubgraphNode.serialize` reads via `readWidgetValue(widget.entityId)`
- `getExplicitHostWidgetValue` drops its now-unused `subgraphNode` param
- `safeWidgetMapper` no longer overrides `storeNodeId/storeName`; those
fields are removed from `SafeWidgetData`
- `useProcessedWidgets` looks up state via `getWidgetState(entityId)`
- `getWidgetIdentity` collapses to `${entityId}:${type}` (type suffix
preserves the PR #9896 invariant for node-def schema drift)
Net -66 LOC. No fallback / accept-either branches: entityId is the single
source of truth for widget identity across the renderer.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
Introduce a canonical `WidgetEntityId` (`graphId:nodeId:name`) sourced
from the widget instance itself, replacing the ad-hoc tuple plus
NodeLocatorId scheme used by app-mode selection.
- Add `src/world/` with branded `WidgetEntityId`, lookup by id, and
value I/O helpers.
- Expose `entityId` on `IBaseWidget` / `BaseWidget` and
`PromotedWidgetView`; surface it through the Vue widget mapper.
- Migrate `appModeStore` selection tuples and pruning to entity-id
identity; legacy NodeId / NodeLocatorId tuples are upgraded forward.
- Update `AppBuilder`, `AppModeWidgetList`, and resize composable to
consume entity ids instead of (nodeId, widgetName) pairs.
Also fix a multi-host primitive bypass bug in `proxyWidgetMigration`:
host values now land on each host's input mirror, never the shared
interior consumer widget, and a marker on the primitive lets later
hosts of the same subgraph reuse the bypassed `SubgraphInput` after
the first host severs the primitive's outputs.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2235-1469-7501-bad7-388dcb5b4a29
- proxyWidgetMigration.ts: collapse three *Result discriminated unions
into a single Outcome<TOk, TReason>; merge quarantineFor into
makeQuarantineEntry; SnapshotLink extends PrimitiveBypassTargetRef;
rename cohortReferencesPrimitive -> cohortDuplicatesPrimitive and
drop mutable counter
- SubgraphNode.serialize: replace for+push+hasSerializableValue flag
with flatMap and .some()
- appModeStore: replace nested for+continue+push with nested flatMap
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
- Add loadOrMigrateExposures(rootGraphId, primary, ...legacy) used by
both top-level host resolution and resolveNestedHost; deduplicates
the 'try primary -> fall back -> migrate to primary' pattern.
- Add readReactivePreviewUrls() that bundles the 4-way reactive
dependency reads with the URL fetch fallback, keeping the comment
about Vue dependency tracking next to the code that depends on it.
- Replace the 3-way previewMediaType ternary with a lookup map +
default via getPreviewMediaType().
- Replace the imperative for-loop + previews.push() + continue chain
with exposures.flatMap(); eliminates the last 'let' in the function.
- Drop the exposurePairs rename map; read exposure.name /
exposure.sourceNodeId / exposure.sourcePreviewName directly.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
Replace five separate vi.hoisted(() => vi.fn()) declarations plus the
useNodeOutputStoreMock factory with a single inline vi.mock factory
that owns the singleton store. Tests configure individual methods via
vi.mocked(useNodeOutputStore().method), matching the project's
vitest-patterns guidance and the pattern used in useGLSLPreview.test.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
- Replace vi.clearAllMocks() + four explicit mockReset() calls with
single vi.resetAllMocks().
- Drop dead nodeOutputStore variable; inline the per-test mock store
assignment.
- Parameterize video/audio media-type cases via it.for.
- Rename the previously misleading 'uses preview exposures by source
preview name' test to clearly target the default-image branch when
previewMediaType is unset.
- Extract arrangePromotedPreview() helper to collapse repeating four-line
setup boilerplate.
- Replace '$$canvas-image-preview' magic strings with the existing
CANVAS_IMAGE_PREVIEW_WIDGET constant.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
Audit finding: 3 of 4 new tests in this PR either lacked the @vue-nodes
tag or relied solely on canvas/state assertions. Bring them in line with
the rest of the @vue-nodes coverage:
- 'Legacy primitive proxy widgets migrate...' — add @vue-nodes tag and
assert host renders 2 migrated widget rows before and after reload.
- 'Nested preview exposures render...' — add explicit .lg-node-widgets
absence assertion (preview-only host has no widgets container).
- 'Legacy unresolvable proxy entry...' — add @vue-nodes tag and assert
the missing_widget label is not rendered on the host.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
Refactor PR-review feedback on SubgraphHelper.ts and promotedWidgets.ts:
- Replace inline boolean checks (that masqueraded as type guards) with
real `is`-predicate functions: `isPromotedWidgetSource`, `isNodeProperty`.
- Drop duplicated `[string, string]` tuple shapes in favor of the existing
`PromotedWidgetEntry` type alias.
- Move validation outside `page.evaluate` so we can use the canonical
`parsePreviewExposures` zod schema and the shared `PromotedWidgetSource`
type from `src/core/...`.
- Eliminate type assertions; narrow via `in` checks and predicate guards.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f3a-a617-70b4-8945-3f0fbd34c1de
Co-authored-by: Amp <amp@ampcode.com>
Two CI Playwright failures in the multi-host primitive fanout test and
the legacy-prefixed proxyWidget normalization test traced to the same
class of mismatch: the migration wrote per-host widget state to a key
the Vue rendering layer didn't read from, and `classify()` accepted
source widgets that `repairCreateSubgraphInput()` then rejected.
Failure 1 — Per-host independence
The migration's `setHostWidgetState` registers values under
`(rootGraphId, hostNodeId, "<name>:<sourceNodeId>:<sourceWidgetName>")`,
but `safeWidgetMapper` produced `storeNodeId/storeName` pointing at the
shared interior key `(rootGraphId, sourceNodeId, sourceWidgetName)`. The
host-scoped value was therefore invisible to Vue; the rendered input
fell back to the empty interior default.
Add `ensureHostWidgetState()` on `PromotedWidgetView` that idempotently
registers a host-scoped store entry seeded with the current effective
value (factored shared registration into `registerHostWidgetState`).
Update `safeWidgetMapper` so promoted widgets call it and expose the
host-scoped `storeNodeId`/`storeName` derived from
`getPromotedWidgetHostStateName`. Two unit tests asserted the previous
interior-keyed identity; updated to assert the host-scoped key while
preserving the same distinct-identity invariant.
Failure 2 — Legacy nested proxy entries
`classify()` resolved source widgets via
`findSourceWidget(...) ?? getPromotableWidgets(...).find(...)`, but
`repairCreateSubgraphInput()` used only `findSourceWidget(...)`. A
legacy entry whose source was a nested SubgraphNode passed
classification then quarantined at repair time, leaving the host with
fewer rendered widgets than expected.
Extract a shared `resolveSourceWidget()` helper applying the same
fallback in both call sites.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f0b-176e-7659-80f5-20323ac54898
Co-authored-by: Amp <amp@ampcode.com>
Replace in-test workflow synthesis with saved-workflow assets so the
subgraph serialization tests load fixtures the same way a user would.
Drop the local mutable-workflow types and the manual setting toggles by
adopting the @vue-nodes tag, which auto-enables Vue Nodes and waits
for the rendered surface.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1f0b-176e-7659-80f5-20323ac54898
Co-authored-by: Amp <amp@ampcode.com>
resolveSubgraphInputTarget previously gated on getTargetWidget() before
the SubgraphNode-host branch, so an outer SubgraphInput linked to a
nested SubgraphNode whose inner slot has no concrete widget resolved
to undefined. The consumer in useGraphNodeManager then fell back to
the interior PromotedWidgetView's deep (sourceNodeId, sourceWidgetName)
identity, which contradicts ADR 0009's host-scoped opaque contract:
identity is (hostNodeLocator, SubgraphInput.name).
Reorder so the SubgraphNode-host branch runs first and returns
(child.id, targetInput.name) regardless of whether the inner slot has
a backing widget. The widget lookup remains the gate for the
plain-interior-node branch, where a non-widget input legitimately has
no promotable target.
Three existing tests asserted the pre-ADR undefined behavior on
nested-SubgraphNode + non-widget inputs. Update those assertions and
test names to reflect the host-opaque contract; capture the inner
node id from the helper since two helper calls collide on the
hard-coded id.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e84-3270-730a-8c95-49a383ffdf20
- proxyWidgetMigration: when a disambiguator is supplied but doesn't
match, only fall back to non-promoted widgets with the same name so
we don't silently bind to a sibling PromotedWidgetView.
- previewExposureChain: chainFromLastStep was using the already-advanced
currentRootGraphId for the leaf, producing an inconsistent
(rootGraphId, sourceNodeId) pair on cycle / no-exposure / max-depth
exits. Use the last pushed step's rootGraphId instead.
- promotedWidgetView.applyValueControlToHost: control-after-generate
was reassigning this.value, which cascades into the shared interior
widget via the value setter. Switch to hydrateHostValue so the
per-host overlay is updated without touching shared state.
- promotionUtils: capture promoteValueWidgetViaSubgraphInput results at
both call sites and emit a warning Sentry breadcrumb on failure
instead of silently dropping {ok:false, reason}.
- LGraphNode.clone: in UUID mode, configure() was overwriting the
borrowed source id with the freshly generated UUID before subclass
hydration could run. Generate the UUID up front and reapply it after
configure() so hydration sees the borrowed id and the cloned node
still gets a fresh identity.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e84-3270-730a-8c95-49a383ffdf20
Co-authored-by: Amp <amp@ampcode.com>
Switch the multi-host primitive fanout test to @vue-nodes and assert
against rendered widget inputs. Previous version asserted the same
string-widget value 8 times instead of covering both promoted widgets
per host.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1e14-fdd4-70f4-a3f7-da15caefcd71
Co-authored-by: Amp <amp@ampcode.com>
Tag the duplicate-widget-names test with @vue-nodes and use DOM
locators (`getNodeLocator`, `getByRole('textbox')`, `enterSubgraph`)
instead of `page.evaluate`-based graph introspection. The outer
node assertion now checks the rendered textbox value directly, and
the inner node assertion enters the outer subgraph and reads the
two distinguishable text inputs from the inner SubgraphNode in the
DOM.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1db0-3c55-75fe-a542-8bfcbcf87d60
Co-authored-by: Amp <amp@ampcode.com>
When a host SubgraphNode's child has deduplicated promoted widget names
(e.g. `text`, `text_1`), the legacy `proxyWidgets` migration was matching
only by widget name and always picked the first widget. The third tuple
element (source-node id disambiguator, e.g. `["3", "text", "2"]`) was
preserved in the normalized entry but never consulted during resolution,
so outer subgraph nodes ended up exposing the wrong interior widget.
Add `findSourceWidget` that prefers a `PromotedWidgetView` whose interior
identity (`sourceNodeId`, `sourceWidgetName`) matches the disambiguator,
falling back to a name match. Wire it into `classify` and
`repairCreateSubgraphInput`.
Update the existing browser test to actually inspect the outer subgraph
node's exposed widget value (the previous assertion only checked the
inner node, so it passed against the regression). Add a unit test for
the disambiguator path. Re-flip the "deep leaf" SubgraphWidgetPromotion
test to assert resolution via the immediate child's PromotedWidgetView
identity, which is the correct behavior under ADR 0009.
Amp-Thread-ID: https://ampcode.com/threads/T-019e1db0-3c55-75fe-a542-8bfcbcf87d60
Co-authored-by: Amp <amp@ampcode.com>
- appModeStore: collapse 4 legacy-tuple migration helpers into one
upgradeAndValidateInput; pruneLinearData becomes a thin map+filter.
- nodeOutputStore: derive *ByExecutionId from the locator index
instead of mirroring state.
- previewExposureStore: demote unused ResolveNestedHostFn export to
a local type.
- Inline two trivial wrapper modules (resolvePromotedWidgetSource,
previewExposureTypes) into their primary consumers.
- previewExposureChain: strip JSDoc, extract leaf builder helper,
fix double-warn bug on cycle detection.
Adds the four node trigger event payloads (property/slot-errors/
slot-links/slot-label) to LGraphEventMap and makes LGraph.trigger()
dispatch via this.events in addition to calling onTrigger.
Removes the leaky onTrigger monkey-patch in AppModeWidgetList.vue,
replacing it with useEventListener on the events target. The
onTrigger callback path is preserved for back-compat with
useGraphNodeManager (see FE-667 for converting that one too).
- 3-key promoted-view cache replaced with a single version counter;
exposes invalidatePromotedViews() so promotionUtils can call it at
the actual mutation site (the missing invalidation was the bug the
multi-key cache was masking).
- Inline single-use key builders into _getPromotedViews.
- Drop id===-1 sentinel guard in clipboard path by fixing
construction order: clone path now assigns id before configure().
- SubgraphNode.serialize.test.ts merged into
SubgraphWidgetPromotion.test.ts as table-driven round-trip
invariants, replacing spy-on-private assertions.
Replaces planner/classifier/repair/quarantine helpers and their tests
with a single proxyWidgetMigration module exercised through black-box
round-trip tests. Hook registry indirection replaced with a static
LGraph.proxyWidgetMigrationFlush field assigned in main.ts.
Includes a real semantic fix: classifier now preserves surviving
primitive targets when other targets are dangling.
Net: -16 files, ~-2,300 LoC in src/core/graph/subgraph/migration/.
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records
@@ -308,6 +307,20 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets',()=>{
test('Unpacking the preview subgraph clears promoted preview state and DOM',async({
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.