Compare commits

...

10 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
26b50f484d fix: update copy on cloud login title and profile popover sign out 2026-03-06 22:23:04 -05:00
pythongosssss
1058b7d12d feat/fix: App mode QA feedback 2 (#9511)
## Summary

Additional fixes and updates based on testing

## Changes

- **What**: 
- add warning to welcome screen & when sharing an app that has had all
outputs removed
- fix target workflow when changing mode via tab right click menu
- change build app text to be conditional "edit" vs "build" depending on
if an app is already defined
- update empty apps sidebar tab button text to make it clearer
- remove templates button from app mode (we will reintroduce this once
we have app templates)
- add "exit to graph" after applying default mode of node graph
- update cancel button to remove item from queue if it hasn't started
yet
- improve scoping of jobs/outputs to the current workflow [not perfect
but should be much improved]
- close sidebar tabs on entering app mode
- change tooltip to be under the workflow menu rather than covering the
button

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9511-feat-fix-App-mode-QA-feedback-2-31b6d73d365081d59bbbc13111100d46)
by [Unito](https://www.unito.io)
2026-03-06 18:57:03 -08:00
jaeone94
8bfd93963f [style] Update error/subgraph node footer design with layered overlay approach (#9360)
## Summary

Refactors the error and subgraph node footer UI by extracting a
dedicated `NodeFooter` component and replacing the CSS `outline`
approach with a layered border overlay for selection/executing state
indicators.

## Changes

- **What**: Extracted `NodeFooter.vue` from `LGraphNode.vue` to
encapsulate the footer tab logic (subgraph enter, error, advanced
inputs). Replaced CSS `outline` with an absolutely-positioned border
overlay div for selection and executing state. Added a separate root
border overlay div for the node body border. Removed unused
`isTransparent` function from `colorUtil.ts`.
- **Dependencies**: None

## Review Focus

- The layered overlay approach (`absolute -inset-[3px] border-3`) for
selection/executing outlines vs the previous `outline-3` approach —
ensures the outline renders outside the node bounds correctly including
the footer area
- `NodeFooter` handles 4 cases: subgraph+error (dual tabs), error only,
subgraph only, advanced inputs — verify edge cases render correctly
- Resize handle bottom offset adjustments for nodes with footers
(`hasFooter`)

## Screenshots
<img width="1142" height="603" alt="image"
src="https://github.com/user-attachments/assets/e0d401f0-8516-4f5f-ab77-48a79530f4bd"
/>
<img width="1175" height="577" alt="image"
src="https://github.com/user-attachments/assets/bcf08fff-728a-491c-add9-5b96d2f3bfce"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9360-style-Update-error-subgraph-node-footer-design-with-layered-overlay-approach-3186d73d365081b2ac31f166f4d1944a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-06 17:51:08 -08:00
Benjamin Lu
3366079f59 test: disable missing model warnings in browser tests (#9513)
Disable missing model warnings in browser tests by default.

Browser tests run without model files on disk, so workflows that embed
model metadata can render differently in CI than the test actually
intends to cover. The viewport screenshot golden had started depending
on the missing-model popup even though the test is only about restoring
an offscreen viewport.

Set `Comfy.Workflow.ShowMissingModelsWarning` to `false` in the shared
Playwright fixture, keep the missing-model dialog coverage by explicitly
enabling the setting in the dialog tests, and update the viewport
screenshot expectation to the no-popup rendering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9513-test-disable-missing-model-warnings-in-browser-tests-31b6d73d365081d1908bfe11ec0c3bc2)
by [Unito](https://www.unito.io)
2026-03-06 17:37:50 -08:00
Johnpaul Chiwetelu
c4dabb8f98 refactor: extract input widget resolution from SubgraphNode configure (#9383)
## Summary

Extract the inner link-resolution loop from
`_internalConfigureAfterSlots` into a private `_resolveInputWidget`
method to reduce cognitive complexity below the sonarjs threshold of 15.

## Changes

- **What**: Extract nested loop body (lines 654-689) into
`_resolveInputWidget` private method in `SubgraphNode.ts`
- Pure refactoring with no behavioral changes

## Review Focus

Straightforward extract-method refactoring. The new method contains the
exact same logic that was previously inline.

Fixes #9297

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9383-refactor-extract-input-widget-resolution-from-SubgraphNode-configure-3196d73d365081ba9124cfd0d312fcb0)
by [Unito](https://www.unito.io)
2026-03-06 23:24:49 +01:00
Alexander Brown
0b73285ca1 fix: extract and harden subgraph node ID deduplication (#9510)
## Summary

Extract and harden subgraph node ID deduplication to prevent widget
store key collisions when multiple subgraph copies share identical node
IDs.

## Changes

- **What**: Extract `deduplicateSubgraphNodeIds` from `LGraph.ts` into
`utils/subgraphDeduplication.ts`, decomposed into focused helpers
(`remapNodeIds`, `findNextAvailableId`, `patchSerialisedLinks`,
`patchPromotedWidgets`, `patchProxyWidgets`). Clone inputs internally so
caller data is never mutated. Add safety limit on ID search to prevent
unbounded loops. Add `console.warn` on remapped IDs matching existing
`ensureGlobalIdUniqueness` behavior. Add test fixture and 5 behavioral
tests covering ID remapping, link patching, promoted widget patching,
proxyWidget patching, and no-op when IDs are unique.

## Review Focus

- The cloning strategy in `deduplicateSubgraphNodeIds` — it
`structuredClone`s subgraphs and rootNodes, returning the clones. The
caller uses `effectiveNodesData` to thread the patched root nodes
through to node creation.
- The `MAX_NODE_ID` safety limit (100M) — is this a reasonable ceiling?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9510-fix-extract-and-harden-subgraph-node-ID-deduplication-31b6d73d365081f48c7de75e2bfc48b3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 21:56:56 +00:00
AustinMroz
7a01be388f More app fixes (#9432)
- Increased the z-index on app mode outputs so that they display above a
zoomed image
- The "view job" button on the job queued toast in mobile app mode will
take you to outputs instead of assets
- Image previews now have a minimum zoom of ~20% and a maximum zoom of
~50x
- The enter panel in linear mode now has a minimum size of ~1/5th screen
size
- In arrange mode, dragging to rearrange inputs will no longer cause a
horizontal scrollbar to appear.
- Videos will now display the first frame instead of a generic video
icon
- Muted/Bypassed nodes can no longer be selected as inputs/outputs, or
be displayed when in app mode.
- Linked input can no longer be selected or displayed
- Adds a share workflow button in app mode and wires up the existing
context menu

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9432-More-app-fixes-31a6d73d365081509cd0ea74bfdc9b95)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-06 13:41:52 -08:00
pythongosssss
3ddff9f7b6 feat: Update workflow menu to allow quick toggling modes (#9436)
## Summary

Adds a quick toggle mode button to the workflow menu for users to easier
discover & change modes

## Changes

- **What**: 
- remove specific app mode rendering
- increase spacing around breadcrumbs menu
- add current mode text to menu
- add base button variant

## Screenshots (if applicable)

<img width="258" height="137" alt="image"
src="https://github.com/user-attachments/assets/2ed7b276-c52c-44cd-b107-399f769574af"
/>
<img width="233" height="172" alt="image"
src="https://github.com/user-attachments/assets/2639d30c-2150-4434-a86b-732649c4b142"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9436-feat-Update-workflow-menu-to-allow-quick-toggling-modes-31a6d73d365081b589eee0e03cd6f1de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 20:03:02 +00:00
pythongosssss
4ff14b5eb9 feat/fix: App mode QA updates (#9439)
## Summary

Various fixes from app mode QA

## Changes

- **What**: 
- fix: prevent inserting nodes from workflow/apps sidebar tabs
- fix: hide json extension in workflow tab
- fix: hide apps nav button in apps tab when already in apps mode
- fix: center text on arrange page
- fix: prevent IoItems from "jumping" due to stale transform after drag
and drop op
- fix: refactor side panels and add custom stable pixel based sizing
- fix: make outputs/inputs lists in app builder scrollable
- fix: fix rerun not working correctly

- feat: add text to interrupt button
- feat: add enter app mode button to builder toolbar
- feat: add tooltip to download button on linear view
- feat: show last output of workflow in arrange tab if available
- feat: show download count in download all button, hide if only 1 asset
to download

## Review Focus

- Rerun - I am not sure why it was triggering widget actions, removing
it seemed like the correct fix
- useStablePrimeVueSplitter - this is a workaround for the fact it uses
percent sizing, I also tried switching to reka-ui splitters, but they
also only support % sizing in our version [pixel based looks to have
been added in a newer version, will log an issue to upgrade & replace
splitters with this]


## Screenshots (if applicable)

<img width="1314" height="1129" alt="image"
src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca"
/>
<img width="511" height="283" alt="image"
src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b"
/>
<img width="254" height="232" alt="image"
src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535"
/>
<img width="487" height="198" alt="image"
src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8"
/>
<img width="378" height="647" alt="image"
src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d"
/>
<img width="1016" height="476" alt="image"
src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817)
by [Unito](https://www.unito.io)
2026-03-06 20:02:19 +00:00
pythongosssss
bae1081a08 fix: update loadWorkflowInMedia test to only assert upload request URL (#9488)
## Summary

Fixes flakey test to only assert that the upload request is made with
the correct URL

## Changes

- **What**
- Replace waitForResponse with waitForRequest for the no_workflow.webp
upload test to only assert the request is initiated with the correct URL
- Move request listener setup before the drag-drop action to avoid race
conditions
- Remove screenshot assertion for the upload case since the upload may
not complete before the screenshot is taken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9488-fix-update-loadWorkflowInMedia-test-to-only-assert-upload-request-URL-31b6d73d365081f69a9aeb1095da7d60)
by [Unito](https://www.unito.io)
2026-03-06 11:38:53 -08:00
91 changed files with 2776 additions and 745 deletions

View File

@@ -36,14 +36,7 @@
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
"ver": "0.3.65"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},

View File

@@ -432,7 +432,10 @@ export const comfyPageFixture = base.extend<{
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true
'Comfy.VersionCompatibility.DisableWarnings': true,
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
})
} catch (e) {
console.error(e)

View File

@@ -172,6 +172,19 @@ export class VueNodeHelpers {
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
await editButton.click()
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
}

View File

@@ -89,6 +89,17 @@ test.describe('Execution error', () => {
})
test.describe('Missing models warning', () => {
test('Should be disabled by default in browser tests', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -819,16 +819,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
const workflowPathA = `${workflowA}.json`
const workflowPathB = `${workflowB}.json`
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowPathA, workflowPathB])
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowPathB)
expect(activeWorkflowName).toEqual(workflowB)
})
})

View File

@@ -35,18 +35,21 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
const shouldUpload = filesWithUpload.has(fileName)
const uploadRequestPromise = shouldUpload
? comfyPage.page.waitForRequest((req) =>
req.url().includes('/upload/')
)
: null
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
if (uploadRequestPromise) {
const request = await uploadRequestPromise
expect(request.url()).toContain('/upload/')
} else {
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
}
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

View File

@@ -13,9 +13,9 @@ test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
})
test('loads from inserted workflow', async ({ comfyPage }) => {
const workflowName = 'single_connected_reroute_node.json'
const workflowName = 'single_connected_reroute_node'
await comfyPage.workflow.setupWorkflowsDirectory({
[workflowName]: 'links/single_connected_reroute_node.json'
[`${workflowName}.json`]: `links/${workflowName}.json`
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -21,14 +21,12 @@ test.describe('Workflows sidebar', () => {
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
'*Unsaved Workflow',
'*Unsaved Workflow (2)'
])
})
@@ -41,37 +39,37 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
expect.arrayContaining(['workflow1', 'workflow2'])
)
})
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json'])
expect.arrayContaining(['workflow1'])
)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json'
'workflow1',
'*workflow1 (Copy)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json',
'*workflow1 (Copy) (3).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
])
})
@@ -85,12 +83,12 @@ test.describe('Workflows sidebar', () => {
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await tab.insertWorkflow(tab.getPersistedItem('workflow1'))
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1.json').click()
await tab.getPersistedItem('workflow1').click()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
@@ -113,22 +111,22 @@ test.describe('Workflows sidebar', () => {
const openedWorkflow = tab.getOpenedItem('foo/bar')
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'foo/baz.json'
'*Unsaved Workflow',
'foo/baz'
])
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
.toEqual(['*Unsaved Workflow', 'workflow3'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow4')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
.toEqual(['*Unsaved Workflow', 'workflow3', 'workflow4'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -184,15 +182,15 @@ test.describe('Workflows sidebar', () => {
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
await comfyPage.menu.topbar.saveWorkflow('workflow5')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
await comfyPage.confirmDialog.click('overwrite')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
})
@@ -212,25 +210,25 @@ test.describe('Workflows sidebar', () => {
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await topbar.saveWorkflow('workflow1')
await topbar.saveWorkflowAs('workflow2')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
.toEqual(['workflow1', 'workflow2'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
.toEqual('workflow2')
await topbar.saveWorkflowAs('workflow1.json')
await topbar.saveWorkflowAs('workflow1')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
// The old workflow1 should be deleted and the new one should be saved.
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
.toEqual(['workflow2', 'workflow1'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
.toEqual('workflow1')
})
test('Does not report warning when switching between opened workflows', async ({
@@ -266,17 +264,15 @@ test.describe('Workflows sidebar', () => {
)
await closeButton.click()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
})
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
@@ -284,7 +280,7 @@ test.describe('Workflows sidebar', () => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -295,14 +291,14 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can delete workflows', async ({ comfyPage }) => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -314,7 +310,7 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
@@ -326,13 +322,11 @@ test.describe('Workflows sidebar', () => {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await workflowsTab.getPersistedItem('workflow1').click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
@@ -344,7 +338,7 @@ test.describe('Workflows sidebar', () => {
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -361,7 +355,7 @@ test.describe('Workflows sidebar', () => {
}
await comfyPage.page.dragAndDrop(
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
'.comfyui-workflows-browse .node-label:has-text("workflow1")',
'#graph-canvas',
{ targetPosition }
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -22,8 +22,10 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -41,8 +43,14 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const ksamplerNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'KSampler' })
.getByTestId('node-inner-wrapper')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-node-stroke-error/
const ERROR_CLASS = /ring-destructive-background/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,9 +18,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
@@ -31,7 +32,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -3,9 +3,15 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -18,6 +24,8 @@ const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
@@ -35,36 +43,11 @@ function openAssets() {
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
function openTemplates() {
useWorkflowTemplateSelectorDialog().show('sidebar')
}
</script>
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
@@ -81,6 +64,21 @@ function openTemplates() {
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
@@ -113,19 +111,6 @@ function openTemplates() {
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('sideToolbar.templates'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.templates')"
class="size-10"
@click="openTemplates"
>
<i class="icon-[comfy--template] size-4" />
</Button>
</div>
</div>
</template>

View File

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

View File

@@ -12,7 +12,10 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import {
LGraphEventMode,
TitleMode
} from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -159,7 +162,8 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (node?.mode !== LGraphEventMode.ALWAYS)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
@@ -192,7 +196,10 @@ function nodeToDisplayTuple(
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@@ -204,131 +211,152 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
<div class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
>
{{ t('linearMode.builder.inputPlaceholder') }}
</div>
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
>
{{ t('linearMode.builder.outputPlaceholder') }}
<span class="font-bold">
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
</span>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
</div>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

@@ -38,8 +38,8 @@
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
{{ $t('builderToolbar.exitToWorkflow') }}
</Button>
</template>
</template>
@@ -58,5 +58,6 @@ defineProps<{
defineEmits<{
viewApp: []
close: []
exitToWorkflow: []
}>()
</script>

View File

@@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
})
function onExitBuilder() {
void appModeStore.exitBuilder()
appModeStore.exitBuilder()
}
</script>

View File

@@ -19,38 +19,31 @@
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
<template v-for="(item, index) in menuItems" :key="item.label">
<div v-if="index > 0" class="my-1 border-t border-border-default" />
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
:disabled="item.disabled"
@click="item.action(close)"
>
<i :class="cn(item.icon, 'size-4')" />
{{ item.label }}
</Button>
</template>
</template>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -60,10 +53,30 @@ import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { setMode } = useAppMode()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const menuItems = computed(() => [
{
label: t('g.save'),
icon: 'icon-[lucide--save]',
disabled: !hasOutputs.value,
action: onSave
},
{
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--square-pen]',
action: onExitBuilder
}
])
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
@@ -75,8 +88,13 @@ async function onSave(close: () => void) {
}
}
function onEnterAppMode(close: () => void) {
setMode('app')
close()
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -22,6 +22,10 @@ const mockApp = vi.hoisted(() => ({
const mockSetMode = vi.hoisted(() => vi.fn())
const mockAppModeStore = vi.hoisted(() => ({
exitBuilder: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
@@ -42,6 +46,10 @@ vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => mockAppModeStore
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
@@ -208,6 +216,16 @@ describe('useAppSetDefaultView', () => {
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onExitToWorkflow exits builder and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onExitToWorkflow()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)

View File

@@ -8,6 +8,7 @@ import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
import { useAppModeStore } from '@/stores/appModeStore'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
@@ -16,6 +17,7 @@ export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
@@ -54,6 +56,10 @@ export function useAppSetDefaultView() {
closeAppliedDialog()
setMode('app')
},
onExitToWorkflow: () => {
closeAppliedDialog()
appModeStore.exitBuilder()
},
onClose: closeAppliedDialog
}
})

View File

@@ -17,6 +17,7 @@
:variant="buttonVariant ?? 'textonly'"
@click="$emit('action')"
>
<i v-if="buttonIcon" :class="buttonIcon" />
{{ buttonLabel }}
</Button>
</div>
@@ -37,6 +38,7 @@ const props = defineProps<{
title?: string
message: string
textClass?: string
buttonIcon?: string
buttonLabel?: string
buttonVariant?: ButtonVariants['variant']
}>()

View File

@@ -5,6 +5,7 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -22,6 +23,7 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
@@ -40,22 +42,48 @@ function handleOpen(open: boolean) {
})
}
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
const tooltipPt = {
root: {
style: { transform: 'translateX(calc(50% - 16px))' }
},
arrow: {
class: '!left-[16px]'
}
}
</script>
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
v-tooltip.bottom="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
showDelay: 300,
hideDelay: 300
hideDelay: 300,
pt: tooltipPt
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="pointer-events-auto relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
>
<i
class="size-4"
@@ -65,15 +93,36 @@ function handleOpen(open: boolean) {
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</div>
</slot>
<DropdownMenuPortal>
<DropdownMenuContent
:align

View File

@@ -2,7 +2,7 @@
<div
class="flex flex-col border-t border-border-default px-4 py-2 text-sm wrap-break-word text-muted-foreground"
>
<p v-if="promptTextReal">
<p v-if="promptTextReal" :class="preserveNewlines && 'whitespace-pre-line'">
{{ promptTextReal }}
</p>
</div>
@@ -11,8 +11,9 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText } = defineProps<{
const { promptText, preserveNewlines = false } = defineProps<{
promptText?: MaybeRefOrGetter<string>
preserveNewlines?: boolean
}>()
const promptTextReal = computed(() => toValue(promptText))

View File

@@ -5,7 +5,7 @@
</Button>
<Button
:disabled
variant="textonly"
:variant="confirmVariant ?? 'textonly'"
:class="confirmClass"
@click="$emit('confirm')"
>
@@ -19,13 +19,21 @@ import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
const { t } = useI18n()
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
const {
cancelText,
confirmText,
confirmClass,
confirmVariant,
optionsDisabled
} = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
confirmVariant?: ButtonVariants['variant']
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()

View File

@@ -2,7 +2,6 @@
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
@@ -18,8 +17,13 @@
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
button-icon="icon-[lucide--hammer]"
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
@action="enterAppMode"
/>
</template>
@@ -32,16 +36,12 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { setMode } = useAppMode()
const { isAppMode, setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function stripAppJsonSuffix(label: string): string {
return label.replace(/\.app\.json$/i, '')
}
function enterAppMode() {
setMode('app')
}

View File

@@ -154,6 +154,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
@@ -163,26 +164,23 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
ensureWorkflowSuffix,
getFilenameDetails,
getWorkflowSuffix
} from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
hideLeafIcon
} = defineProps<{
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
title: string
filter?: (workflow: ComfyWorkflow) => boolean
searchSubject: string
dataTestid: string
labelTransform?: (label: string) => string
hideLeafIcon?: boolean
}>()
const { t } = useI18n()
const { isAppMode } = useAppMode()
const applyFilter = (workflows: ComfyWorkflow[]) =>
filter ? workflows.filter(filter) : workflows
@@ -304,14 +302,18 @@ const renderTreeNode = (
},
contextMenuItems() {
return [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
...(isAppMode.value
? []
: [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
}
]),
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
@@ -326,8 +328,7 @@ const renderTreeNode = (
}
: { handleClick }
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
return {
key: node.key,

View File

@@ -9,7 +9,8 @@ const panY = ref(0.0)
function handleWheel(e: WheelEvent) {
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
if (!zoomPaneEl || (e.deltaY < 0 ? zoom.value > 1200 : zoom.value < -500))
return
zoom.value -= e.deltaY
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()

View File

@@ -20,6 +20,7 @@ export const buttonVariants = cva({
'destructive-textonly':
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -49,6 +50,7 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'base',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -0,0 +1,133 @@
import type { SplitterResizeEndEvent } from 'primevue/splitter'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useStablePrimeVueSplitterSizer } from './useStablePrimeVueSplitterSizer'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useStorage: <T>(_key: string, defaultValue: T) => ref(defaultValue)
}
})
function createPanel(width: number) {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetWidth', { value: width })
return ref(el)
}
function resizeEndEvent(): SplitterResizeEndEvent {
return { originalEvent: new Event('mouseup'), sizes: [] }
}
async function flushWatcher() {
await nextTick()
await nextTick()
}
describe('useStablePrimeVueSplitterSizer', () => {
it('captures pixel widths on resize end and applies on trigger', async () => {
const panelRef = createPanel(400)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-capture' }],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('400px')
expect(panelRef.value!.style.flexGrow).toBe('0')
expect(panelRef.value!.style.flexShrink).toBe('0')
})
it('does not apply styles when no stored width exists', async () => {
const panelRef = createPanel(300)
const trigger = ref(0)
useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-no-stored' }],
[trigger]
)
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('')
})
it('re-applies stored widths when watch sources change', async () => {
const panelRef = createPanel(500)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-reapply' }],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
panelRef.value!.style.flexBasis = ''
panelRef.value!.style.flexGrow = ''
panelRef.value!.style.flexShrink = ''
trigger.value++
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('500px')
expect(panelRef.value!.style.flexGrow).toBe('0')
expect(panelRef.value!.style.flexShrink).toBe('0')
})
it('handles multiple panels independently', async () => {
const leftRef = createPanel(300)
const rightRef = createPanel(250)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: leftRef, storageKey: 'test-multi-left' },
{ ref: rightRef, storageKey: 'test-multi-right' }
],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(leftRef.value!.style.flexBasis).toBe('300px')
expect(rightRef.value!.style.flexBasis).toBe('250px')
})
it('skips panels with null refs', async () => {
const nullRef = ref(null)
const validRef = createPanel(200)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: nullRef, storageKey: 'test-null' },
{ ref: validRef, storageKey: 'test-valid' }
],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(validRef.value!.style.flexBasis).toBe('200px')
})
})

View File

@@ -0,0 +1,64 @@
import type { SplitterResizeEndEvent } from 'primevue/splitter'
import type { WatchSource } from 'vue'
import { unrefElement, useStorage } from '@vueuse/core'
import type { MaybeComputedElementRef } from '@vueuse/core'
import { nextTick, watch } from 'vue'
interface PanelConfig {
ref: MaybeComputedElementRef
storageKey: string
}
/**
* Works around PrimeVue Splitter not properly initializing flexBasis
* when panels are conditionally rendered. Captures pixel widths on
* resize end and re-applies them as rigid flex values (flex: 0 0 Xpx)
* when watched sources change (e.g. tab switch, panel toggle).
*
* @param panels - array of panel configs with template ref and storage key
* @param watchSources - reactive sources that trigger re-application
*/
export function useStablePrimeVueSplitterSizer(
panels: PanelConfig[],
watchSources: WatchSource[]
) {
const storedWidths = panels.map((panel) => ({
ref: panel.ref,
width: useStorage<number | null>(panel.storageKey, null)
}))
function resolveElement(
ref: MaybeComputedElementRef
): HTMLElement | undefined {
return unrefElement(ref) as HTMLElement | undefined
}
function applyStoredWidths() {
for (const { ref, width } of storedWidths) {
const el = resolveElement(ref)
if (!el || width.value === null) continue
el.style.flexBasis = `${width.value}px`
el.style.flexGrow = '0'
el.style.flexShrink = '0'
}
}
function onResizeEnd(_event: SplitterResizeEndEvent) {
for (const { ref, width } of storedWidths) {
const el = resolveElement(ref)
if (el) width.value = el.offsetWidth
}
}
watch(
watchSources,
async () => {
await nextTick()
applyStoredWidths()
},
{ immediate: true }
)
return { onResizeEnd }
}

View File

@@ -40,12 +40,21 @@ const mockMenuItemStore = vi.hoisted(() => ({
hasSeenLinear: false
}))
const mockCanvasStore = vi.hoisted(() => ({
linearMode: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn()
enterBuilder: vi.fn(),
pruneLinearData: vi.fn(
(
data?: Partial<{
inputs: [number | string, string][]
outputs: (number | string)[]
}>
) => ({
inputs: data?.inputs ?? [],
outputs: data?.outputs ?? []
})
),
selectedInputs: [] as [number | string, string][],
selectedOutputs: [] as (number | string)[]
}))
const mockFeatureFlags = vi.hoisted(() => ({
@@ -73,14 +82,12 @@ vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: vi.fn(() => mockMenuItemStore)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
vi.mock('@/composables/useErrorHandling', () => ({}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => mockFeatureFlags)
}))
@@ -110,8 +117,9 @@ describe('useWorkflowActionsMenu', () => {
mockBookmarkStore.isBookmarked.mockReturnValue(false)
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
mockMenuItemStore.hasSeenLinear = false
mockCanvasStore.linearMode = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockAppModeStore.selectedInputs.length = 0
mockAppModeStore.selectedOutputs.length = 0
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
@@ -192,7 +200,11 @@ describe('useWorkflowActionsMenu', () => {
it('shows "go to workflow mode" when in linear mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockCanvasStore.linearMode = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
@@ -310,6 +322,22 @@ describe('useWorkflowActionsMenu', () => {
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
})
it('shows "Edit app" when workflow has linear data', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
} as ComfyWorkflow
mockAppModeStore.selectedInputs.push([1, 'widget'])
mockAppModeStore.selectedOutputs.push(2)
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const item = findItem(menuItems.value, 'breadcrumbsMenu.editBuilderMode')
expect(item).toBeDefined()
expect(item.isNew).toBeTruthy()
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true

View File

@@ -2,14 +2,16 @@ import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { openShareDialog } from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -51,9 +53,9 @@ export function useWorkflowActionsMenu(
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const menuItemStore = useMenuItemStore()
const canvasStore = useCanvasStore()
const { flags } = useFeatureFlags()
const { enterBuilder } = useAppModeStore()
const appModeStore = useAppModeStore()
const { enterBuilder, pruneLinearData } = appModeStore
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
@@ -93,12 +95,15 @@ export function useWorkflowActionsMenu(
items.push(item)
}
const isLinearMode = canvasStore.linearMode
const workflowMode =
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
const isLinearMode = workflowMode === 'app'
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
await ensureWorkflowActive(targetWorkflow.value)
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
@@ -191,8 +196,9 @@ export function useWorkflowActionsMenu(
id: 'share',
label: t('breadcrumbsMenu.share'),
icon: 'icon-[comfy--send]',
command: async () => {},
visible: false
command: () =>
openShareDialog().catch(useErrorHandling().toastErrorHandler),
visible: isCloud && flags.workflowSharingEnabled
})
addItem({
@@ -214,11 +220,31 @@ export function useWorkflowActionsMenu(
prependSeparator: true
})
const isActive = workflow === workflowStore.activeWorkflow
const rawLd = isActive
? {
inputs: appModeStore.selectedInputs,
outputs: appModeStore.selectedOutputs
}
: workflow?.changeTracker?.activeState?.extra?.linearData
let hasLinearData: boolean
if (rawLd) {
const { inputs, outputs } = pruneLinearData(rawLd)
hasLinearData = inputs.length > 0 || outputs.length > 0
} else {
hasLinearData = workflow?.path?.endsWith('.app.json') ?? false
}
addItem({
id: 'enter-builder-mode',
label: t('breadcrumbsMenu.enterBuilderMode'),
label: hasLinearData
? t('breadcrumbsMenu.editBuilderMode')
: t('breadcrumbsMenu.enterBuilderMode'),
icon: 'icon-[lucide--hammer]',
command: () => enterBuilder(),
command: async () => {
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder()
},
visible: showAppModeItems,
isNew: true
})

View File

@@ -9,6 +9,7 @@ import {
LiteGraph,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -17,6 +18,10 @@ import {
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
import { test } from './__fixtures__/testExtensions'
function swapNodes(nodes: LGraphNode[]) {
@@ -656,3 +661,121 @@ describe('Subgraph Unpacking', () => {
expect(definitionIds).toContain(subgraph.id)
})
})
describe('deduplicateSubgraphNodeIds (via configure)', () => {
const SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' as UUID
const SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' as UUID
const SHARED_NODE_IDS = [3, 8, 37]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('dummy', DummyNode)
})
function loadFixture(): SerialisableGraph {
return structuredClone(duplicateSubgraphNodeIds)
}
function configureFromFixture() {
const graphData = loadFixture()
const graph = new LGraph()
graph.configure(graphData)
return { graph, graphData }
}
function nodeIdSet(graph: LGraph, subgraphId: UUID) {
return new Set(graph.subgraphs.get(subgraphId)!.nodes.map((n) => n.id))
}
it('remaps duplicate node IDs so subgraphs have no overlap', () => {
const { graph } = configureFromFixture()
const idsA = nodeIdSet(graph, SUBGRAPH_A)
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const id of SHARED_NODE_IDS) {
expect(idsA.has(id as NodeId)).toBe(true)
}
for (const id of idsA) {
expect(idsB.has(id)).toBe(false)
}
})
it('patches link references in remapped subgraph', () => {
const { graph } = configureFromFixture()
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) {
expect(idsB.has(link.origin_id)).toBe(true)
expect(idsB.has(link.target_id)).toBe(true)
}
})
it('patches promoted widget references in remapped subgraph', () => {
const { graph } = configureFromFixture()
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const widget of graph.subgraphs.get(SUBGRAPH_B)!.widgets) {
expect(idsB.has(widget.id)).toBe(true)
}
})
it('patches proxyWidgets in root-level nodes referencing remapped IDs', () => {
const { graph } = configureFromFixture()
const idsA = new Set(
graph.subgraphs.get(SUBGRAPH_A)!.nodes.map((n) => String(n.id))
)
const idsB = new Set(
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
)
const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets
expect(Array.isArray(pw102)).toBe(true)
for (const entry of pw102 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsA.has(String(entry[0]))).toBe(true)
}
const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets
expect(Array.isArray(pw103)).toBe(true)
for (const entry of pw103 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsB.has(String(entry[0]))).toBe(true)
}
})
it('patches proxyWidgets inside nested subgraph nodes', () => {
const graph = new LGraph()
graph.configure(structuredClone(nestedSubgraphProxyWidgets))
const idsB = new Set(
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
)
const innerNode = graph.subgraphs
.get(SUBGRAPH_A)!
.nodes.find((n) => n.id === (50 as NodeId))
const pw = innerNode?.properties?.proxyWidgets
expect(Array.isArray(pw)).toBe(true)
for (const entry of pw as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsB.has(String(entry[0]))).toBe(true)
}
})
it('throws when node ID space is exhausted', () => {
expect(() => {
const graph = new LGraph()
graph.configure(structuredClone(nodeIdSpaceExhausted))
}).toThrow('Node ID space exhausted')
})
it('is a no-op when subgraph node IDs are already unique', () => {
const graph = new LGraph()
graph.configure(structuredClone(uniqueSubgraphNodeIds))
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12]))
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
})
})

View File

@@ -77,6 +77,7 @@ import type {
SerialisableReroute
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
export type {
LGraphTriggerAction,
@@ -2475,19 +2476,40 @@ export class LGraph
this[i] = data[i]
}
// Subgraph definitions
// Subgraph definitions — deduplicate node IDs before configuring.
// deduplicateSubgraphNodeIds clones internally to avoid mutating
// the caller's data (e.g. reactive Pinia state).
const subgraphs = data.definitions?.subgraphs
let effectiveNodesData = nodesData
if (subgraphs) {
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
for (const subgraph of subgraphs)
this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
const reservedNodeIds = new Set<number>()
for (const node of this._nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
}
for (const sg of this.subgraphs.values()) {
for (const node of sg.nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
}
}
for (const n of nodesData ?? []) {
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
}
if (this.isRootGraph) {
const reservedNodeIds = nodesData
?.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
this.ensureGlobalIdUniqueness(reservedNodeIds)
const deduplicated = this.isRootGraph
? deduplicateSubgraphNodeIds(
subgraphs,
reservedNodeIds,
this.state,
nodesData
)
: undefined
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
for (const subgraph of finalSubgraphs)
this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
let error = false
@@ -2495,8 +2517,8 @@ export class LGraph
// create nodes
this._nodes = []
if (nodesData) {
for (const n_info of nodesData) {
if (effectiveNodesData) {
for (const n_info of effectiveNodesData) {
// stored info
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {

View File

@@ -0,0 +1,163 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow with two subgraph definitions whose internal nodes share
* identical IDs [3, 8, 37]. Reproduces the widget-state collision bug
* where copied subgraphs overwrote each other's widget store entries.
*
* SubgraphA (node 102): widgets reference node 3, link 3→8
* SubgraphB (node 103): widgets reference node 8, link 3→37
*/
export const duplicateSubgraphNodeIds = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -0,0 +1,177 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow where SubgraphA contains a nested SubgraphNode referencing
* SubgraphB. Both subgraph definitions share internal node IDs [3, 8, 37].
*
* The nested SubgraphNode (id 50, inside SubgraphA) has proxyWidgets
* pointing at SubgraphB's node 8. After deduplication remaps SubgraphB's
* nodes, the nested proxyWidgets must also be patched.
*
* SubgraphA (node 102): widgets reference node 3, link 3→8,
* contains nested SubgraphNode(50) → SubgraphB with proxyWidget ['8']
* SubgraphB (node 103): widgets reference node 8, link 3→37
*/
export const nestedSubgraphProxyWidgets = {
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
},
{
id: 50,
type: '22222222-2222-4222-8222-222222222222',
pos: [200, 0],
size: [100, 50],
flags: {},
order: 3,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -0,0 +1,172 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000)
* and root node 100_000_000 reserves the only remaining candidate ID.
*
* Both subgraph definitions share node IDs [3, 8, 37]. When SubgraphB's
* duplicates need remapping, candidate 100_000_000 is already reserved,
* so the next candidate (100_000_001) exceeds MAX_NODE_ID and must throw.
*/
export const nodeIdSpaceExhausted = {
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
version: 1,
revision: 0,
state: {
lastNodeId: 99_999_999,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
},
{
id: 100_000_000,
type: 'dummy',
pos: [600, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -0,0 +1,163 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow with two subgraph definitions whose internal nodes already
* have unique IDs. Deduplication should be a no-op — all IDs, links,
* widgets, and proxyWidgets pass through unchanged.
*
* SubgraphA (node 102): nodes [10, 11, 12], link 10→11, widget ref 10
* SubgraphB (node 103): nodes [20, 21, 22], link 20→22, widget ref 21
*/
export const uniqueSubgraphNodeIds = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['10', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['21', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 10, name: 'seed' }],
nodes: [
{
id: 10,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 11,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 12,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 10,
origin_slot: 0,
target_id: 11,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 21, name: 'prompt' }],
nodes: [
{
id: 20,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 21,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 22,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 20,
origin_slot: 0,
target_id: 22,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -649,49 +649,52 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._addSubgraphInputListeners(subgraphInput, input)
// Find the first widget that this slot is connected to
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
//Manually find input since target_slot can't be trusted
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
// No widget - ignore this link
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
this._resolveInputWidget(subgraphInput, input)
}
this._syncPromotions()
}
private _resolveInputWidget(
subgraphInput: SubgraphInput,
input: INodeInputSlot
) {
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,

View File

@@ -0,0 +1,164 @@
import type { LGraphState } from '../LGraph'
import type { NodeId } from '../LGraphNode'
import type {
ExportedSubgraph,
ExposedWidget,
ISerialisedNode,
SerialisableLLink
} from '../types/serialisation'
const MAX_NODE_ID = 100_000_000
interface DeduplicationResult {
subgraphs: ExportedSubgraph[]
rootNodes: ISerialisedNode[] | undefined
}
/**
* Pre-deduplicates node IDs across serialized subgraph definitions before
* they are configured. This prevents widget store key collisions when
* multiple subgraph copies contain nodes with the same IDs.
*
* Also patches proxyWidgets in root-level nodes that reference the
* remapped inner node IDs.
*
* Returns deep clones of the inputs — the originals are never mutated.
*
* @param subgraphs - Serialized subgraph definitions to deduplicate
* @param reservedNodeIds - Node IDs already in use by root-level nodes
* @param state - Graph state containing the `lastNodeId` counter (mutated)
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
reservedNodeIds: Set<number>,
state: LGraphState,
rootNodes?: ISerialisedNode[]
): DeduplicationResult {
const clonedSubgraphs = structuredClone(subgraphs)
const clonedRootNodes = rootNodes ? structuredClone(rootNodes) : undefined
const usedNodeIds = new Set(reservedNodeIds)
const subgraphIdSet = new Set(clonedSubgraphs.map((sg) => sg.id))
const remapBySubgraph = new Map<string, Map<NodeId, NodeId>>()
for (const subgraph of clonedSubgraphs) {
const remappedIds = remapNodeIds(subgraph.nodes ?? [], usedNodeIds, state)
if (remappedIds.size === 0) continue
remapBySubgraph.set(subgraph.id, remappedIds)
patchSerialisedLinks(subgraph.links ?? [], remappedIds)
patchPromotedWidgets(subgraph.widgets ?? [], remappedIds)
}
for (const subgraph of clonedSubgraphs) {
patchProxyWidgets(subgraph.nodes ?? [], subgraphIdSet, remapBySubgraph)
}
if (clonedRootNodes) {
patchProxyWidgets(clonedRootNodes, subgraphIdSet, remapBySubgraph)
}
return { subgraphs: clonedSubgraphs, rootNodes: clonedRootNodes }
}
/**
* Remaps duplicate node IDs to unique values, updating `usedNodeIds`
* and `state.lastNodeId` as new IDs are allocated.
*
* @returns A map of old ID → new ID for nodes that were remapped.
*/
function remapNodeIds(
nodes: ISerialisedNode[],
usedNodeIds: Set<number>,
state: LGraphState
): Map<NodeId, NodeId> {
const remappedIds = new Map<NodeId, NodeId>()
for (const node of nodes) {
const id = node.id
if (typeof id !== 'number') continue
if (usedNodeIds.has(id)) {
const newId = findNextAvailableId(usedNodeIds, state)
remappedIds.set(id, newId)
node.id = newId
usedNodeIds.add(newId as number)
console.warn(
`LiteGraph: duplicate subgraph node ID ${id} remapped to ${newId}`
)
} else {
usedNodeIds.add(id)
if (id > state.lastNodeId) state.lastNodeId = id
}
}
return remappedIds
}
/**
* Finds the next unused node ID by incrementing `state.lastNodeId`.
* Throws if the ID space is exhausted.
*/
function findNextAvailableId(
usedNodeIds: Set<number>,
state: LGraphState
): NodeId {
while (true) {
const nextId = state.lastNodeId + 1
if (nextId > MAX_NODE_ID) {
throw new Error('Node ID space exhausted')
}
state.lastNodeId = nextId
if (!usedNodeIds.has(nextId)) return nextId as NodeId
}
}
/** Patches origin_id / target_id in serialized links. */
function patchSerialisedLinks(
links: SerialisableLLink[],
remappedIds: Map<NodeId, NodeId>
): void {
for (const link of links) {
const newOrigin = remappedIds.get(link.origin_id)
if (newOrigin !== undefined) link.origin_id = newOrigin
const newTarget = remappedIds.get(link.target_id)
if (newTarget !== undefined) link.target_id = newTarget
}
}
/** Patches promoted widget node references. */
function patchPromotedWidgets(
widgets: ExposedWidget[],
remappedIds: Map<NodeId, NodeId>
): void {
for (const widget of widgets) {
const newId = remappedIds.get(widget.id)
if (newId !== undefined) widget.id = newId
}
}
/** Patches proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],
subgraphIdSet: Set<string>,
remapBySubgraph: Map<string, Map<NodeId, NodeId>>
): void {
for (const node of rootNodes) {
if (!subgraphIdSet.has(node.type)) continue
const remappedIds = remapBySubgraph.get(node.type)
if (!remappedIds) continue
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) continue
for (const entry of proxyWidgets) {
if (!Array.isArray(entry)) continue
const oldId = Number(entry[0]) as NodeId
const newId = remappedIds.get(oldId)
if (newId !== undefined) entry[0] = String(newId)
}
}
}

View File

@@ -1330,6 +1330,7 @@
"Rename": "Rename",
"Save": "Save",
"Save As": "Save As",
"Share": "Share",
"Show Settings Dialog": "Show Settings Dialog",
"Set Subgraph Description": "Set Subgraph Description",
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
@@ -2024,7 +2025,7 @@
"whitelistInfo": "About non-whitelisted sites"
},
"login": {
"title": "Log in to your account",
"title": "Welcome back! Log in to your account",
"useApiKey": "Comfy API Key",
"signInOrSignUp": "Sign In / Sign Up",
"forgotPasswordError": "Failed to send password reset email",
@@ -2081,7 +2082,7 @@
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
},
"signOut": {
"signOut": "Log Out",
"signOut": "Sign Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
@@ -2598,13 +2599,17 @@
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
"enterBuilderMode": "App builder",
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
"enterNewName": "Enter new name",
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
"graph": "Graph",
"app": "App",
"enterNodeGraph": "Enter node graph",
"share": "Share"
},
"shortcuts": {
@@ -3004,6 +3009,11 @@
"share": "Share",
"shareTooltip": "Share workflow"
},
"shareNoOutputs": {
"title": "App has no outputs",
"message": "You're about to share an app without outputs. It can't be used until an output is connected.\n\nShare anyway?",
"shareAnyway": "Share anyway"
},
"shareWorkflow": {
"shareLinkTab": "Share",
"publishToHubTab": "Publish",
@@ -3153,6 +3163,7 @@
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
@@ -3160,25 +3171,28 @@
"runCount": "Number of runs",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download All",
"downloadAll": "Download {count} assets from this run",
"viewJob": "View Job",
"enterNodeGraph": "Enter node graph",
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app"
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",
"oneOutput": "1 output"
},
"appModeToolbar": {
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
"enterAppMode": "Enter app mode"
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3203,7 +3217,10 @@
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”",
"unknownWidget": "Widget not visible"
"unknownWidget": "Widget not visible",
"inputPlaceholder": "Inputs will show up here",
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
},
"queue": {
"clickToClear": "Click to clear queue",
@@ -3515,10 +3532,12 @@
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"viewApp": "View app",
"exitToWorkflow": "Exit to workflow",
"emptyWorkflowTitle": "This workflow has no nodes",
"emptyWorkflowPrompt": "Do you want to start with a template?"
},
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
}
}

View File

@@ -1,18 +1,57 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowStore } from '../../management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { t } from '@/i18n'
const DIALOG_KEY = 'global-share-workflow'
export function useShareDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
function showNoOutputsDialogIfRequired(share: () => void) {
const wf = workflowStore.activeWorkflow
if (!wf) return share()
const isAppDefault = wf.initialMode === 'app'
const linearData = wf.changeTracker?.activeState?.extra?.linearData
const { outputs } = pruneLinearData(linearData)
if (isAppDefault && outputs.length === 0) {
const dialog = showConfirmDialog({
headerProps: {
title: t('shareNoOutputs.title')
},
props: {
promptText: t('shareNoOutputs.message'),
preserveNewlines: true
},
footerProps: {
confirmText: t('shareNoOutputs.shareAnyway'),
confirmVariant: 'secondary',
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: () => {
dialogStore.closeDialog(dialog)
share()
}
}
})
return
}
share()
}
function showShareDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ShareWorkflowDialogContent,
@@ -29,6 +68,10 @@ export function useShareDialog() {
})
}
function show() {
showNoOutputsDialogIfRequired(showShareDialog)
}
return {
show,
hide

View File

@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
const { id, name } = defineProps<{
id: string
isSelectInputsMode: boolean
enable: boolean
name: string
}>()
@@ -25,7 +25,7 @@ function togglePromotion() {
</script>
<template>
<div
v-if="isSelectInputsMode"
v-if="enable"
class="pointer-events-auto relative col-span-2 flex cursor-pointer flex-row gap-1"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent

View File

@@ -2,6 +2,9 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
src: string
@@ -13,7 +16,11 @@ const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane v-if="!mobile" v-slot="slotProps" class="w-full flex-1">
<ZoomPane
v-if="!mobile"
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img
ref="imageRef"
:src

View File

@@ -1,18 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
const { t } = useI18n()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const nodeOutputStore = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const existingOutput = computed(() => {
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) continue
const results = flattenNodeOutput([nodeId, nodeOutput])
if (results.length > 0) return results[0]
}
return undefined
})
</script>
<template>
<MediaOutputPreview
v-if="existingOutput"
:output="existingOutput"
class="px-12 py-24"
/>
<div
v-if="hasOutputs"
v-else-if="hasOutputs"
role="article"
data-testid="arrange-preview"
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
@@ -23,7 +48,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
<p class="mb-0 font-bold text-base-foreground">
{{ t('linearMode.arrange.outputs') }}
</p>
<p>{{ t('linearMode.arrange.resultsLabel') }}</p>
<p class="text-center">{{ t('linearMode.arrange.resultsLabel') }}</p>
</div>
</div>
<div

View File

@@ -11,6 +11,7 @@ import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
@@ -44,7 +45,7 @@ const props = defineProps<{
mobile?: boolean
}>()
defineEmits<{ navigateAssets: [] }>()
defineEmits<{ navigateOutputs: [] }>()
//NOTE: due to batching, will never be greater than 2
const pendingJobQueues = ref(0)
@@ -72,7 +73,7 @@ const mappedSelections = computed(() => {
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = resolveNode(nodeId)
if (!node) continue
if (node?.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
@@ -105,6 +106,7 @@ function getDropIndicator(node: LGraphNode) {
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
@@ -261,7 +263,7 @@ defineExpose({ runButtonClick })
<Button
v-if="mobile"
variant="inverted"
@click="$emit('navigateAssets')"
@click="$emit('navigateOutputs')"
>
{{ t('linearMode.viewJob') }}
</Button>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -15,31 +15,18 @@ import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Preview3d = defineAsyncComponent(
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
)
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const queueStore = useQueueStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs } = useOutputHistory()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
@@ -50,12 +37,14 @@ const selectedItem = ref<AssetItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
canShowPreview.value = sel.canShowPreview
latentPreview.value = sel.latentPreviewUrl
showSkeleton.value = sel.showSkeleton ?? false
}
function downloadAsset(item?: AssetItem) {
@@ -73,23 +62,18 @@ async function loadWorkflow(item: AssetItem | undefined) {
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
changeTracker.updateState([workflow], changeTracker.undoQueue)
await changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
if (!runButtonClick) return
await loadWorkflow(selectedItem.value)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
runButtonClick(e)
}
</script>
<template>
<section
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
data-testid="linear-output-info"
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
>
@@ -106,6 +90,7 @@ async function rerun(e: Event) {
</template>
<Button
v-if="selectedOutput"
v-tooltip.top="t('g.download')"
size="icon"
:aria-label="t('g.download')"
@click="
@@ -117,23 +102,28 @@ async function rerun(e: Event) {
<i class="icon-[lucide--download]" />
</Button>
<Button
v-if="!executionStore.isIdle && !selectedItem"
v-if="isWorkflowActive && !selectedItem"
variant="destructive"
size="icon"
:aria-label="t('menu.interrupt')"
@click="commandStore.execute('Comfy.Interrupt')"
@click="cancelActiveWorkflowJobs()"
>
<i class="icon-[lucide--x]" />
{{ t('linearMode.cancelThisRun') }}
</Button>
<Popover
v-if="selectedItem"
:entries="[
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
command: () => downloadAsset(selectedItem)
},
{ separator: true },
...(allOutputs(selectedItem).length > 1
? [
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll', {
count: allOutputs(selectedItem).length
}),
command: () => downloadAsset(selectedItem)
},
{ separator: true }
]
: []),
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
@@ -143,34 +133,16 @@ async function rerun(e: Event) {
/>
</section>
<ImagePreview
v-if="
(canShowPreview && latentPreview) ||
getMediaType(selectedOutput) === 'images'
"
v-if="canShowPreview && latentPreview"
:mobile
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
:src="latentPreview"
/>
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="flex-1 object-contain md:p-3 md:contain-size"
<MediaOutputPreview
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"
class="m-auto w-full"
controls
:src="selectedOutput!.url"
/>
<article
v-else-if="getMediaType(selectedOutput) === 'text'"
class="m-auto my-12 w-full max-w-lg overflow-y-auto"
v-text="selectedOutput!.url"
/>
<Preview3d
v-else-if="getMediaType(selectedOutput) === '3d'"
:model-url="selectedOutput!.url"
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<LinearWelcome v-else />
<div
@@ -184,7 +156,7 @@ async function rerun(e: Event) {
/>
<OutputHistory
v-if="!isBuilderMode"
class="min-w-0"
class="z-10 min-w-0"
@update-selection="handleSelection"
/>
<LinearFeedback

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useQueueStore } from '@/stores/queueStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -18,14 +18,14 @@ const {
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
</script>
<template>
<div
:class="
cn(
'relative h-2 bg-secondary-background transition-opacity',
queueStore.runningTasks.length === 0 && 'opacity-0',
!executionStore.isActiveWorkflowRunning && 'opacity-0',
rounded && 'rounded-sm',
className
)

View File

@@ -4,12 +4,18 @@ import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const { t } = useI18n()
const { setMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
const workflowStore = useWorkflowStore()
const isAppDefault = computed(
() => workflowStore.activeWorkflow?.initialMode === 'app'
)
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</script>
@@ -47,6 +53,18 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<p v-if="!hasNodes" class="mt-0 max-w-md text-sm text-base-foreground">
{{ t('linearMode.emptyWorkflowExplanation') }}
</p>
<p
v-if="hasNodes && isAppDefault"
class="mt-0 max-w-md text-sm text-base-foreground"
>
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
<template #count>
<span class="font-bold text-warning-background">{{
t('linearMode.welcome.oneOutput')
}}</span>
</template>
</i18n-t>
</p>
<div class="flex flex-row gap-2">
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.backToWorkflow') }}

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const Preview3d = defineAsyncComponent(
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
)
defineOptions({ inheritAttrs: false })
const { output } = defineProps<{
output: ResultItemImpl
mobile?: boolean
}>()
const attrs = useAttrs()
</script>
<template>
<ImagePreview
v-if="getMediaType(output) === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
/>
<VideoPreview
v-else-if="getMediaType(output) === 'video'"
:src="output.url"
:class="
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
"
/>
<audio
v-else-if="getMediaType(output) === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="getMediaType(output) === 'text'"
:class="
cn('m-auto my-12 w-full max-w-lg overflow-y-auto', attrs.class as string)
"
v-text="output.url"
/>
<Preview3d
v-else-if="getMediaType(output) === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
</template>

View File

@@ -187,7 +187,7 @@ const menuEntries = computed<MenuItem[]>(() => [
:style="{ translate }"
>
<div class="absolute h-full w-screen overflow-y-auto contain-size">
<LinearControls mobile @navigate-assets="activeIndex = 2" />
<LinearControls mobile @navigate-outputs="activeIndex = 1" />
</div>
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"

View File

@@ -25,12 +25,15 @@ import type {
} from '@/renderer/extensions/linearMode/linearModeTypes'
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const emit = defineEmits<{
updateSelection: [selection: OutputSelection]
@@ -42,7 +45,7 @@ const queueCount = computed(
const itemClass = cn(
'shrink-0 cursor-pointer rounded-lg border-2 border-transparent p-1 outline-none',
'data-[state=checked]:border-interface-panel-job-progress-border'
'relative data-[state=checked]:border-interface-panel-job-progress-border'
)
const hasActiveContent = computed(
@@ -55,10 +58,7 @@ const visibleHistory = computed(() =>
const selectableItems = computed(() => {
const items: SelectionValue[] = []
if (
queueCount.value > 0 &&
store.activeWorkflowInProgressItems.length === 0
) {
if (mayBeActiveWorkflowPending.value) {
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
}
for (const item of store.activeWorkflowInProgressItems) {
@@ -120,7 +120,7 @@ function doEmit() {
(i) => i.id === sel.itemId
)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: true })
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
} else if (item.state === 'latent') {
emit('updateSelection', {
canShowPreview: true,
@@ -146,6 +146,23 @@ function doEmit() {
watchEffect(doEmit)
// On load or workflow tab switch, select the most recent item.
// Prefer in-progress items for this workflow, then history, skipping
// the global pending slot which may belong to another workflow.
watch(
() => workflowStore.activeWorkflow?.path,
(path) => {
if (!path) return
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else {
selectFirstHistory()
}
},
{ immediate: true }
)
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
@@ -303,9 +320,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
/>
<div
v-if="
queueCount > 0 && store.activeWorkflowInProgressItems.length === 0
"
v-if="mayBeActiveWorkflowPending"
:ref="selectedRef('slot:pending')"
v-bind="itemAttrs('slot:pending')"
:class="itemClass"

View File

@@ -6,6 +6,8 @@ import {
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
const { output } = defineProps<{
output: ResultItemImpl
}>()
@@ -19,6 +21,16 @@ const { output } = defineProps<{
height="40"
:src="output.url"
/>
<template v-else-if="getMediaType(output) === 'video'">
<video
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
preload="metadata"
width="40"
height="40"
:src="output.url"
/>
<VideoPlayOverlay size="sm" />
</template>
<i
v-else
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"

View File

@@ -14,6 +14,7 @@ export interface OutputSelection {
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string
showSkeleton?: boolean
}
export type SelectionValue =

View File

@@ -124,6 +124,7 @@ describe('linearOutputStore', () => {
it('auto-selects skeleton on first job start when no selection', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
@@ -132,6 +133,7 @@ describe('linearOutputStore', () => {
it('transitions to latent on preview', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
const itemId = store.inProgressItems[0].id
@@ -265,6 +267,7 @@ describe('linearOutputStore', () => {
// selectAsLatest simulates "following the latest output"
store.selectAsLatest('history:asset-1:0')
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
// Following latest → auto-select new skeleton
@@ -286,6 +289,7 @@ describe('linearOutputStore', () => {
it('falls back selection when selected item is removed', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
const firstId = `slot:${store.inProgressItems[0].id}`
expect(store.selectedId).toBe(firstId)
@@ -400,6 +404,8 @@ describe('linearOutputStore', () => {
it('two sequential runs: selection clears after each resolve', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
// Run 1: 3 outputs
store.onJobStart('job-1')
@@ -738,6 +744,34 @@ describe('linearOutputStore', () => {
expect(imageItems[0].output?.nodeId).toBe('2')
})
it('does not auto-select for jobs belonging to another workflow', () => {
const store = useLinearOutputStore()
// User is on workflow-b, following latest
activeWorkflowPathRef.value = 'workflows/app-b.json'
store.selectAsLatest('history:asset-b:0')
// Job from workflow-a starts
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
// Should NOT yank selection to the other workflow's slot
expect(store.selectedId).toBe('history:asset-b:0')
})
it('auto-selects for jobs belonging to the active workflow', () => {
const store = useLinearOutputStore()
activeWorkflowPathRef.value = 'workflows/app-a.json'
store.selectAsLatest('history:asset-a:0')
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
// Should auto-select since job matches active workflow
expect(store.selectedId?.startsWith('slot:')).toBe(true)
})
it('ignores execution events when not in app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()

View File

@@ -67,7 +67,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
inProgressItems.value = [item, ...inProgressItems.value]
trackedJobId.value = jobId
autoSelect(`slot:${item.id}`)
autoSelect(`slot:${item.id}`, jobId)
}
let raf: number | null = null
@@ -88,7 +88,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
state: 'latent',
latentPreviewUrl: url
}))
if (wasEmpty) autoSelect(`slot:${existing.id}`)
if (wasEmpty) autoSelect(`slot:${existing.id}`, jobId)
return
}
@@ -103,7 +103,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
}
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
autoSelect(`slot:${item.id}`)
autoSelect(`slot:${item.id}`, jobId)
})
}
@@ -136,7 +136,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
output: newOutputs[0],
latentPreviewUrl: undefined
}
autoSelect(`slot:${imageItem.id}`)
autoSelect(`slot:${imageItem.id}`, jobId)
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
id: makeItemId(jobId),
@@ -162,7 +162,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
state: 'image' as const,
output: o
}))
autoSelect(`slot:${newItems[0].id}`)
autoSelect(`slot:${newItems[0].id}`, jobId)
inProgressItems.value = [...newItems, ...inProgressItems.value]
}
@@ -226,7 +226,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
isFollowing.value = true
}
function autoSelect(slotId: string) {
function autoSelect(slotId: string, jobId: string) {
// Only auto-select if the job belongs to the active workflow
const path = workflowStore.activeWorkflow?.path
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
selectedId.value = slotId

View File

@@ -11,9 +11,13 @@ import { ResultItemImpl } from '@/stores/queueStore'
const mediaRef = ref<AssetItem[]>([])
const pendingResolveRef = ref(new Set<string>())
const inProgressItemsRef = ref<InProgressItem[]>([])
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
const selectedIdRef = ref<string | null>(null)
const activeWorkflowPathRef = ref<string>('workflows/test.json')
const jobIdToPathRef = ref(new Map<string, string>())
const isActiveWorkflowRunningRef = ref(false)
const runningTasksRef = ref<Array<{ jobId: string }>>([])
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
const selectAsLatestFn = vi.fn()
const resolveIfReadyFn = vi.fn()
@@ -40,6 +44,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
get inProgressItems() {
return inProgressItemsRef.value
},
get activeWorkflowInProgressItems() {
return activeWorkflowInProgressItemsRef.value
},
get selectedId() {
return selectedIdRef.value
},
@@ -61,10 +68,27 @@ vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get jobIdToSessionWorkflowPath() {
return jobIdToPathRef.value
},
get isActiveWorkflowRunning() {
return isActiveWorkflowRunningRef.value
}
})
}))
vi.mock('@/stores/queueStore', async (importOriginal) => {
return {
...(await importOriginal()),
useQueueStore: () => ({
get runningTasks() {
return runningTasksRef.value
},
get pendingTasks() {
return pendingTasksRef.value
}
})
}
})
const { jobDetailResults } = vi.hoisted(() => ({
jobDetailResults: new Map<string, unknown>()
}))
@@ -128,9 +152,13 @@ describe(useOutputHistory, () => {
mediaRef.value = []
pendingResolveRef.value = new Set()
inProgressItemsRef.value = []
activeWorkflowInProgressItemsRef.value = []
selectedIdRef.value = null
activeWorkflowPathRef.value = 'workflows/test.json'
jobIdToPathRef.value = new Map()
isActiveWorkflowRunningRef.value = false
runningTasksRef.value = []
pendingTasksRef.value = []
resolvedOutputsCacheRef.clear()
jobDetailResults.clear()
selectAsLatestFn.mockReset()
@@ -378,4 +406,54 @@ describe(useOutputHistory, () => {
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
})
})
describe('mayBeActiveWorkflowPending', () => {
it('returns false when no tasks are queued', () => {
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns false when there are active in-progress items', () => {
activeWorkflowInProgressItemsRef.value = [
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
]
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns true when a running task matches the active workflow', () => {
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(true)
})
it('returns true when a pending task matches the active workflow', () => {
pendingTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(true)
})
it('returns false when tasks belong to another workflow', () => {
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/other.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns false when no workflow path is set', () => {
activeWorkflowPathRef.value = ''
runningTasksRef.value = [{ jobId: 'job-1' }]
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
})
})

View File

@@ -1,4 +1,5 @@
import { useAsyncState } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
@@ -9,14 +10,20 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
export function useOutputHistory(): {
outputs: IAssetsProvider
allOutputs: (item?: AssetItem) => ResultItemImpl[]
selectFirstHistory: () => void
mayBeActiveWorkflowPending: ComputedRef<boolean>
isWorkflowActive: ComputedRef<boolean>
cancelActiveWorkflowJobs: () => Promise<void>
} {
const backingOutputs = useMediaAssets('output')
void backingOutputs.fetchMediaList()
@@ -24,6 +31,37 @@ export function useOutputHistory(): {
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const appModeStore = useAppModeStore()
const queueStore = useQueueStore()
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
const path = workflowStore.activeWorkflow?.path
if (!path) return false
return (
executionStore.jobIdToSessionWorkflowPath.get(String(task.jobId)) === path
)
}
function hasActiveWorkflowJobs(): boolean {
if (!workflowStore.activeWorkflow?.path) return false
return (
queueStore.runningTasks.some(matchesActiveWorkflow) ||
queueStore.pendingTasks.some(matchesActiveWorkflow)
)
}
// True when there are queued/running jobs for the active workflow but no
// in-progress output items yet.
const mayBeActiveWorkflowPending = computed(() => {
if (linearStore.activeWorkflowInProgressItems.length > 0) return false
return hasActiveWorkflowJobs()
})
// True when the active workflow has running/pending jobs or in-progress items.
const isWorkflowActive = computed(
() =>
linearStore.activeWorkflowInProgressItems.length > 0 ||
hasActiveWorkflowJobs()
)
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
@@ -140,5 +178,29 @@ export function useOutputHistory(): {
}
})
return { outputs, allOutputs, selectFirstHistory }
async function cancelActiveWorkflowJobs() {
if (!workflowStore.activeWorkflow?.path) return
// Interrupt the running job if it belongs to this workflow
if (queueStore.runningTasks.some(matchesActiveWorkflow)) {
void useCommandStore().execute('Comfy.Interrupt')
} else {
// Delete first pending job for this workflow from the queue
for (const task of queueStore.pendingTasks) {
if (matchesActiveWorkflow(task)) {
await api.deleteItem('queue', String(task.jobId))
break
}
}
}
}
return {
outputs,
allOutputs,
selectFirstHistory,
mayBeActiveWorkflowPending,
isWorkflowActive,
cancelActiveWorkflowJobs
}
}

View File

@@ -41,6 +41,23 @@ vi.mock(
})
)
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getNodeByLocatorId: vi.fn(() => ({
isSubgraphNode: () => false
}))
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
@@ -184,8 +201,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-3')
// Root div should have the selection class
expect(wrapper.classes()).toContain('outline-node-component-outline')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-component-outline')
})
it('should render progress indicator when executing prop is true', () => {
@@ -193,7 +215,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
// Root div should have the executing class
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {

View File

@@ -9,25 +9,11 @@
:data-node-id="nodeData.id"
:class="
cn(
'group/node lg-node absolute bg-node-component-header-surface text-sm',
'min-h-(--node-height) w-(--node-width) min-w-[225px] contain-layout contain-style',
shapeClass,
'flex touch-none flex-col',
'border border-solid border-component-node-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'ring-node-component-ring hover:ring-7',
'outline-3 outline-transparent focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
'group/node lg-node absolute text-sm',
'flex min-w-[225px] flex-col contain-layout contain-style',
cursorClass,
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
},
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -37,9 +23,7 @@
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
opacity: nodeOpacity
}
]"
v-bind="remainingPointerHandlers"
@@ -50,173 +34,173 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<!-- Selection/Execution Outline Overlay -->
<AppOutput
v-if="
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
lgraphNode?.constructor?.nodeData?.output_node &&
isSelectOutputsMode &&
nodeData.mode === LGraphEventMode.ALWAYS
"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
v-if="isCollapsed && executing && progress !== undefined"
v-if="isSelected || executing"
data-testid="node-state-outline-overlay"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
<!-- Root Border Overlay -->
<div
:class="
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
<div
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'min-h-(--node-height) w-(--node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
}
)
"
:style="{
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
class="flex flex-1 flex-col gap-1 rounded-b-2xl bg-component-node-background pt-1 pb-3"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges v-bind="badges" :pricing="undefined" class="mt-auto" />
</div>
</template>
<div
v-if="
(hasAnyError && showErrorsTabEnabled) ||
lgraphNode?.isSubgraphNode() ||
showAdvancedState ||
showAdvancedInputsButton
"
:class="
cn(
'-z-1 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs',
!isCollapsed && '-mt-5 h-12'
)
"
>
<Button
v-if="lgraphNode?.isSubgraphNode()"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none',
hasAnyError &&
showErrorsTabEnabled &&
!nodeData.color &&
'bg-node-component-header-surface',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span class="truncate">{{
hasAnyError && showErrorsTabEnabled
? t('g.enter')
: t('g.enterSubgraph')
}}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</Button>
<Button
v-if="hasAnyError && showErrorsTabEnabled"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none bg-error hover:bg-destructive-background-hover',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
@click.stop="useRightSidePanelStore().openPanel('errors')"
>
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</Button>
<div
:class="
cn(
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
bodyRoundingClass
)
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<!-- Advanced inputs (non-subgraph nodes only) -->
<Button
v-if="
!lgraphNode?.isSubgraphNode() &&
(showAdvancedState || showAdvancedInputsButton)
"
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges
v-if="!isTransparentHeaderless"
v-bind="badges"
:pricing="undefined"
class="mt-auto"
/>
</div>
</template>
</div>
<NodeFooter
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
:shape="nodeData.shape"
@enter-subgraph="handleEnterSubgraph"
@open-errors="handleOpenErrors"
@toggle-advanced="handleToggleAdvanced"
/>
<template
v-if="!isCollapsed && nodeData.resizable !== false && !isSelectMode"
>
@@ -229,6 +213,8 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -270,7 +256,6 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useAppMode } from '@/composables/useAppMode'
@@ -310,13 +295,13 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -326,6 +311,7 @@ import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeFooter from './NodeFooter.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
@@ -563,53 +549,104 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
return 'border-0'
return ''
})
const outlineClass = computed(() => {
return cn(
isSelected.value && 'outline-node-component-outline',
hasAnyError.value && 'outline-node-stroke-error',
executing.value && 'outline-node-stroke-executing'
)
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
})
const bodyRoundingClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-2xl'
default:
return 'rounded-b-2xl'
}
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
return ''
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
return 'rounded-tl-2xl rounded-br-2xl'
default:
return 'rounded-2xl'
}
})
const isTransparentHeaderless = computed(
() =>
!displayHeader.value &&
!!nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
const rootBorderShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[20px] rounded-br-[20px]'
: 'rounded-tl-2xl rounded-br-2xl'
default:
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
}
})
const selectionShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[23px] rounded-br-[23px]'
: 'rounded-tl-[19px] rounded-br-[19px]'
default:
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
return ''
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
return 'before:rounded-tl-2xl before:rounded-br-2xl'
default:
return 'before:rounded-2xl'
}
@@ -624,6 +661,16 @@ const handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const rightSidePanelStore = useRightSidePanelStore()
const handleOpenErrors = () => {
rightSidePanelStore.openPanel('errors')
}
const handleToggleAdvanced = () => {
showAdvancedState.value = !showAdvancedState.value
}
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
@@ -703,7 +750,6 @@ const showAdvancedState = customRef((track, trigger) => {
if (node instanceof SubgraphNode) {
// Do not modify internalState for subgraph nodes
const rightSidePanelStore = useRightSidePanelStore()
if (value) {
rightSidePanelStore.focusSection('advanced-inputs')
} else {

View File

@@ -0,0 +1,183 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 2: Error Only (Full Width) -->
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 3: Subgraph only (Full Width) -->
<template v-else-if="isSubgraph">
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<div
v-else-if="showAdvancedInputsButton || showAdvancedState"
class="relative -z-1 -mt-5 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs"
>
<Button
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="$emit('toggleAdvanced')"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const props = defineProps<Props>()
defineEmits<{
(e: 'enterSubgraph'): void
(e: 'openErrors'): void
(e: 'toggleAdvanced'): void
}>()
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
}
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>

View File

@@ -53,7 +53,11 @@
/>
</div>
<!-- Widget Component -->
<AppInput :id="widget.id" :name="widget.name" :is-select-inputs-mode>
<AppInput
:id="widget.id"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
@@ -89,6 +93,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -154,6 +159,9 @@ onErrorCaptured((error) => {
return false
})
const canSelectInputs = computed(
() => isSelectInputsMode.value && nodeData?.mode === LGraphEventMode.ALWAYS
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(

View File

@@ -314,18 +314,22 @@ export class DraggableList extends EventTarget {
unsetDraggableItem() {
this.draggableItem.style = null
this.draggableItem.classList.remove('is-draggable')
this.draggableItem.classList.add('is-idle')
this.draggableItem = null
}
unsetItemState() {
this.getIdleItems().forEach((item) => {
// @ts-expect-error fixme ts strict error
this.getIdleItems().forEach((item: HTMLElement) => {
delete item.dataset.isAbove
// @ts-expect-error fixme ts strict error
delete item.dataset.isToggled
// @ts-expect-error fixme ts strict error
item.style.transform = ''
// Defer re-adding is-idle (which enables CSS transitions) until after
// the browser paints items in their final positions. Without this,
// the transition animates the stale drag transform.
item.classList.remove('is-idle')
requestAnimationFrame(() => {
item.classList.add('is-idle')
})
})
}

View File

@@ -7,6 +7,7 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
@@ -26,20 +27,25 @@ export const useAppModeStore = defineStore('appMode', () => {
return !!app.rootGraph?.nodes?.length
})
function loadSelections(data: Partial<LinearData> | undefined) {
// Prune entries referencing nodes deleted in workflow mode.
// Only check node existence, not widgets — dynamic widgets can
// hide/show other widgets so a missing widget does not mean stale data.
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
// Prune entries referencing nodes deleted in workflow mode.
// Only check node existence, not widgets — dynamic widgets can
// hide/show other widgets so a missing widget does not mean stale data.
const inputs = app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs
const outputs = app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
return {
inputs: app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
}
}
function loadSelections(data: Partial<LinearData> | undefined) {
const { inputs, outputs } = pruneLinearData(data)
selectedInputs.splice(0, selectedInputs.length, ...inputs)
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
}
@@ -105,6 +111,8 @@ export const useAppModeStore = defineStore('appMode', () => {
return
}
useSidebarTabStore().activeSidebarTabId = null
setMode(
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'
@@ -112,7 +120,7 @@ export const useAppModeStore = defineStore('appMode', () => {
)
}
async function exitBuilder() {
function exitBuilder() {
resetSelectedToWorkflow()
setMode('graph')
}
@@ -122,6 +130,7 @@ export const useAppModeStore = defineStore('appMode', () => {
exitBuilder,
hasNodes,
hasOutputs,
pruneLinearData,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs

View File

@@ -549,6 +549,13 @@ export const useExecutionStore = defineStore('execution', () => {
() => runningJobIds.value.length
)
const isActiveWorkflowRunning = computed(() => {
if (!activeJobId.value) return false
const path = workflowStore.activeWorkflow?.path
if (!path) return false
return jobIdToSessionWorkflowPath.value.get(activeJobId.value) === path
})
return {
isIdle,
clientId,
@@ -568,6 +575,7 @@ export const useExecutionStore = defineStore('execution', () => {
runningJobIds,
runningWorkflowCount,
initializingJobIds,
isActiveWorkflowRunning,
isJobInitializing,
clearInitializationByJobId,
clearInitializationByJobIds,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import type { MaybeElement } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
@@ -19,6 +20,7 @@ import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBa
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
@@ -39,9 +41,6 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
// Builder panel is always on the opposite side of the sidebar.
// In arrange mode we render 3 panels to match the overlay structure,
// so the same stateKey percentage maps to the same pixel width.
const showLeftBuilder = computed(
() => !sidebarOnLeft.value && isArrangeMode.value
)
@@ -67,6 +66,25 @@ function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
return SIDEBAR_MIN_SIZE
}
// Remount splitter when panel structure changes so initializePanels()
// properly sets flexBasis for the current set of panels.
const splitterKey = computed(() => {
const left = hasLeftPanel.value ? 'L' : ''
const right = hasRightPanel.value ? 'R' : ''
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
})
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
const rightPanelRef = useTemplateRef<MaybeElement>('rightPanel')
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: leftPanelRef, storageKey: 'Comfy.LinearView.LeftPanelWidth' },
{ ref: rightPanelRef, storageKey: 'Comfy.LinearView.RightPanelWidth' }
],
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
@@ -86,16 +104,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
</div>
<Splitter
:key="isArrangeMode ? 'arrange' : 'normal'"
:key="splitterKey"
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
:state-storage="isArrangeMode ? 'local' : undefined"
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
@resizestart="$event.originalEvent.preventDefault()"
@resizeend="onResizeEnd"
>
<SplitterPanel
v-if="hasLeftPanel"
id="linearLeftPanel"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
@@ -104,17 +121,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
"
:class="
cn(
'arrange-panel outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-min'
'arrange-panel overflow-hidden outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
>
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="flex h-full border-r border-border-subtle"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
@@ -126,8 +141,8 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
class="relative flex min-w-0 flex-col gap-4 text-muted-foreground outline-none"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
>
<LinearProgressBar
class="absolute top-0 left-0 z-21 w-[calc(100%+16px)]"
@@ -144,22 +159,20 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
id="linearRightPanel"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'arrange-panel outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-min'
'arrange-panel overflow-hidden outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
>
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
@@ -167,7 +180,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
/>
<div
v-else-if="activeTab"
class="flex h-full border-l border-border-subtle"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>