Compare commits

..

11 Commits

Author SHA1 Message Date
Benjamin Lu
16e54b7b01 fix: drop unused menu type exports 2026-03-21 12:00:09 -07:00
Benjamin Lu
d07c8750e6 refactor: consolidate job and asset menus 2026-03-21 11:59:23 -07:00
Alexander Brown
02322a13d7 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-20 11:31:16 -07:00
Benjamin Lu
bb4abb599e test: use real i18n in menu tests 2026-03-19 21:42:17 -07:00
Benjamin Lu
39fcc230e3 test: fix job history menu item selector 2026-03-19 20:20:31 -07:00
Benjamin Lu
e8e18f0213 fix: restore reka menu item semantics 2026-03-19 20:01:30 -07:00
Benjamin Lu
8bc72145b3 merge: resolve origin/main into codex/trigger-owned-context-menus 2026-03-19 19:31:23 -07:00
Benjamin Lu
69b3930dd6 fix: keep sidebar menus usable 2026-03-19 19:24:21 -07:00
Benjamin Lu
ac06a08d92 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-19 11:51:59 -07:00
Benjamin Lu
ef546e3da8 fix: address context menu review feedback 2026-03-19 11:49:45 -07:00
Benjamin Lu
3af52bd4e8 refactor: use trigger-owned context menus 2026-03-19 03:27:01 -07:00
338 changed files with 4635 additions and 13297 deletions

View File

@@ -30,7 +30,6 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -18,7 +18,6 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:

View File

@@ -51,9 +51,6 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
class MaintenanceTaskRunner {
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState

View File

@@ -1,169 +0,0 @@
{
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"pos": [400, 300],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 120,
"lastLinkId": 276,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Slot Drift Duplicate Links",
"inputNode": {
"id": -10,
"bounding": [0, 300, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 300, 120, 60]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 120,
"type": "ComfySwitchNode",
"title": "Switch (CFG)",
"pos": [100, 100],
"size": [200, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [257, 271, 276]
}
],
"properties": { "Node name for S&R": "ComfySwitchNode" },
"widgets_values": []
},
{
"id": 85,
"type": "KSamplerAdvanced",
"pos": [400, 50],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 276 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
},
{
"id": 86,
"type": "KSamplerAdvanced",
"pos": [400, 350],
"size": [270, 262],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 271 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
}
],
"groups": [],
"links": [
{
"id": 257,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 271,
"origin_id": 120,
"origin_slot": 0,
"target_id": 86,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 276,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] },
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -1,407 +0,0 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -68,41 +68,6 @@ export class AppModeHelper {
await this.comfyPage.nextFrame()
}
/**
* Inject linearData into the current graph and enter app mode.
*
* Serializes the graph, injects linearData with the given inputs and
* auto-detected output node IDs, then reloads so the appModeStore
* picks up the data via its activeWorkflow watcher.
*
* @param inputs - Widget selections as [nodeId, widgetName] tuples
*/
async enterAppModeWithInputs(inputs: [string, string][]) {
await this.page.evaluate(async (inputTuples) => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
}, inputs)
await this.comfyPage.nextFrame()
await this.toggleAppMode()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
@@ -160,42 +125,4 @@ export class AppModeHelper {
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -91,12 +91,6 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -3,6 +3,9 @@ import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
private jobsRouteHandler: ((route: Route) => void) | null = null
private queueJobs: Array<Record<string, unknown>> = []
private historyJobs: Array<Record<string, unknown>> = []
constructor(private readonly page: Page) {}
@@ -13,6 +16,26 @@ export class QueueHelper {
running: number = 0,
pending: number = 0
): Promise<void> {
const baseTime = Date.now()
this.queueJobs = [
...Array.from({ length: running }, (_, i) => ({
id: `running-${i}`,
status: 'in_progress',
create_time: baseTime - i * 1000,
execution_start_time: baseTime - 5000 - i * 1000,
execution_end_time: null,
priority: i + 1
})),
...Array.from({ length: pending }, (_, i) => ({
id: `pending-${i}`,
status: 'pending',
create_time: baseTime - (running + i) * 1000,
execution_start_time: null,
execution_end_time: null,
priority: running + i + 1
}))
]
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
@@ -35,6 +58,7 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -43,6 +67,30 @@ export class QueueHelper {
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const baseTime = Date.now()
this.historyJobs = jobs.map((job, index) => {
const completed = job.status === 'success'
return {
id: job.promptId,
status: completed ? 'completed' : 'failed',
create_time: baseTime - index * 1000,
execution_start_time: baseTime - 5000 - index * 1000,
execution_end_time: baseTime - index * 1000,
outputs_count: completed ? 1 : 0,
workflow_id: `workflow-${job.promptId}`,
preview_output: completed
? {
filename: `${job.promptId}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
: null
}
})
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
@@ -61,6 +109,44 @@ export class QueueHelper {
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
await this.installJobsRoute()
}
private async installJobsRoute() {
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const statuses =
url.searchParams
.get('status')
?.split(',')
.filter((status) => status.length > 0) ?? []
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 200)
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
(job) => statuses.length === 0 || statuses.includes(String(job.status))
)
const paginatedJobs = jobs.slice(offset, offset + limit)
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: paginatedJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + paginatedJobs.length < jobs.length
}
})
})
}
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
}
/**
@@ -75,5 +161,9 @@ export class QueueHelper {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
if (this.jobsRouteHandler) {
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
this.jobsRouteHandler = null
}
}
}

View File

@@ -28,15 +28,10 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -69,7 +64,6 @@ export const TestIds = {
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
@@ -81,10 +75,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -110,4 +100,3 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -281,14 +281,6 @@ export class NodeReference {
getType(): Promise<string> {
return this.getProperty('type')
}
async centerOnNode(): Promise<void> {
await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
window.app!.canvas.centerOnNode(node)
}, this.id)
await this.comfyPage.nextFrame()
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -16,30 +14,3 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -1,168 +0,0 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
* All widgets from the default graph are selected so the panel scrolls,
* pushing the last widget's dropdown to the clipping boundary.
*/
const DEFAULT_INPUTS: [string, string][] = [
['4', 'ckpt_name'],
['6', 'text'],
['7', 'text'],
['5', 'width'],
['5', 'height'],
['5', 'batch_size'],
['3', 'seed'],
['3', 'steps'],
['3', 'cfg'],
['3', 'sampler_name'],
['3', 'scheduler'],
['3', 'denoise'],
['9', 'filename_prefix']
]
function isClippedByAnyAncestor(el: Element): boolean {
const child = el.getBoundingClientRect()
let parent = el.parentElement
while (parent) {
const overflow = getComputedStyle(parent).overflow
if (overflow !== 'visible') {
const p = parent.getBoundingClientRect()
if (
child.top < p.top ||
child.bottom > p.bottom ||
child.left < p.left ||
child.right > p.right
) {
return true
}
}
parent = parent.parentElement
}
return false
}
/** Add a node to the graph by type and return its ID. */
async function addNode(page: Page, nodeType: string): Promise<string> {
return page.evaluate((type) => {
const node = window.app!.graph.add(
window.LiteGraph!.createNode(type, undefined, {})
)
return String(node!.id)
}, nodeType)
}
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Select dropdown is not clipped in app mode panel', async ({
comfyPage
}) => {
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[saveVideoId, 'codec']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 })
const isInViewport = await overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
test('FormDropdown popup is not clipped in app mode panel', async ({
comfyPage
}) => {
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[loadImageId, 'image']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -30,18 +30,10 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await page.mouse.click(seedPos.x, seedPos.y)
await comfyPage.nextFrame()
// Select an output node
@@ -56,15 +48,9 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
const saveImagePos = await saveImageRef.getPosition()
// Click left edge — the right side is hidden by the builder panel
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
await comfyPage.nextFrame()
return subgraphNode
@@ -94,15 +80,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Rename from builder input-select sidebar via menu', async ({
comfyPage
}) => {
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
@@ -111,11 +91,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
await appMode.renameWidget(menu, 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
const workflowName = `${new Date().getTime()} builder-input`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
@@ -124,24 +104,6 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
).toBeVisible()
})
test('Rename from builder input-select sidebar via double-click', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToInputs()
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)

View File

@@ -38,13 +38,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
@@ -99,13 +102,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',

View File

@@ -1,318 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -169,19 +165,17 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
@@ -210,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -23,85 +23,4 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
})
// Regression: duplicate links with shifted target_slot (widget-to-input
// conversion) caused the wrong link to survive during deduplication.
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
// Find cfg inputs by name (slot indices shift due to widget-to-input)
const cfgInput85 = ksampler85.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfgInput86 = ksampler86.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfg85Linked = cfgInput85?.link != null
const cfg86Linked = cfgInput86?.link != null
// Verify the surviving links exist in the subgraph link map
const cfg85LinkValid =
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
const cfg86LinkValid =
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (link.origin_id === 120 && link.target_id === 85)
cfgLinkToNode85Count++
}
return {
cfg85Linked,
cfg86Linked,
cfg85LinkValid,
cfg86LinkValid,
cfg85LinkId: cfgInput85?.link ?? null,
cfg86LinkId: cfgInput86?.link ?? null,
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
switchOutputLinkCount,
cfgLinkToNode85Count
}
})
expect(result).not.toHaveProperty('error')
// Both KSamplerAdvanced nodes must have their cfg input connected
expect(result.cfg85Linked).toBe(true)
expect(result.cfg86Linked).toBe(true)
// Links must exist in the subgraph link map
expect(result.cfg85LinkValid).toBe(true)
expect(result.cfg86LinkValid).toBe(true)
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
expect(result.switchOutputLinkCount).toBe(2)
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
expect(result.cfgLinkToNode85Count).toBe(1)
// Output link IDs must match the input link IDs (source/target integrity)
expect(result.switchOutputLinkIds).toEqual(
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
)
})
})

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
@@ -9,10 +11,58 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
@@ -20,7 +70,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
@@ -28,7 +78,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Workflow info section visible', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
@@ -36,13 +86,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await comfyPage.appMode.toggleAppMode()
await enterGraphMode(comfyPage)
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
@@ -51,7 +101,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,38 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockHistory([
{ promptId: 'history-asset-1', status: 'success' }
])
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
await expect(
comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
).toBeVisible()
})
test('actions menu closes on scroll', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.hover()
await assetCard.getByRole('button', { name: /more options/i }).click()
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await comfyPage.page.evaluate(() => {
window.dispatchEvent(new Event('scroll'))
})
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -0,0 +1,53 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockQueueState()
await comfyPage.queue.mockHistory([
{ promptId: 'history-job-1', status: 'success' }
])
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
await expect(
comfyPage.page.locator('[data-job-id="history-job-1"]')
).toBeVisible()
})
test('hover popover and actions menu stay clickable', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.hover()
const popover = comfyPage.page.locator('.job-details-popover')
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: /^copy$/i }).click()
await jobRow.hover()
const moreButton = jobRow.locator('.job-actions-menu-trigger')
await expect(moreButton).toBeVisible()
await moreButton.click()
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
const box = await menuPanel.boundingBox()
if (!box) {
throw new Error('Job actions menu did not render a bounding box')
}
await comfyPage.page.mouse.move(
box.x + box.width / 2,
box.y + Math.min(box.height / 2, 24)
)
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -1,86 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -1,117 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -1,132 +0,0 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe(
'Subgraph slot alignment after LG layout scale',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

View File

@@ -206,31 +206,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -47,46 +47,6 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,14 +19,10 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

View File

@@ -46,16 +46,4 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await textarea.blur()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
})

View File

@@ -5,7 +5,6 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
import testingLibrary from 'eslint-plugin-testing-library'
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
@@ -272,20 +271,6 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
plugins: { 'testing-library': testingLibrary },
rules: {
'testing-library/prefer-screen-queries': 'error',
'testing-library/no-container': 'error',
'testing-library/no-node-access': 'error',
'testing-library/no-wait-for-multiple-assertions': 'error',
'testing-library/prefer-find-by': 'error',
'testing-library/prefer-presence-queries': 'error',
'testing-library/prefer-user-event': 'error',
'testing-library/no-debugging-utils': 'error'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {

View File

@@ -6,6 +6,7 @@ const config: KnipConfig = {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
@@ -13,23 +14,25 @@ const config: KnipConfig = {
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/i18n.ts'],
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}']
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
}
},
ignoreBinaries: ['python3'],
ignoreBinaries: ['python3', 'gh', 'generate'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
@@ -37,12 +40,19 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
],
ignore: [
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'packages/ingest-types/src/types.gen.ts',
'packages/ingest-types/src/zod.gen.ts',
'packages/ingest-types/openapi-ts.config.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
@@ -50,8 +60,17 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
.join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},

View File

@@ -11,7 +11,7 @@ export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
const hasBrowserTestsChanges = stagedFiles

View File

@@ -36,6 +36,5 @@
"targetName": "e2e"
}
}
],
"analytics": false
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.3",
"version": "1.43.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -135,9 +135,6 @@
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/vue": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -156,7 +153,6 @@
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fast-check": "catalog:",
@@ -181,7 +177,9 @@
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-primeui": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",

View File

@@ -12,9 +12,7 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "catalog:"
"@iconify/utils": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -15,7 +15,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

@@ -1,3 +0,0 @@
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
</svg>

Before

Width:  |  Height:  |  Size: 534 B

View File

@@ -631,10 +631,3 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
mediaType === '3D'
)
}
export function formatTime(seconds: number): string {
if (isNaN(seconds) || seconds === 0) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}

1063
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -34,9 +34,6 @@ catalog:
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
'@testing-library/vue': ^8.1.0
'@tiptap/core': ^2.27.2
'@tiptap/extension-link': ^2.27.2
'@tiptap/extension-table': ^2.27.2
@@ -70,7 +67,6 @@ catalog:
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
@@ -83,11 +79,11 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
nx: 22.5.2
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0

View File

@@ -9,10 +9,13 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -20,6 +23,7 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -94,17 +98,12 @@ onMounted(() => {
}
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -25,8 +25,10 @@
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#FFF",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
@@ -35,6 +37,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#222",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -26,8 +26,10 @@
"CLEAR_BACKGROUND_COLOR": "lightgray",
"NODE_TITLE_COLOR": "#222",
"NODE_SELECTED_TITLE_COLOR": "#000",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#444",
"NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#F7F7F7",
"NODE_DEFAULT_BGCOLOR": "#F5F5F5",
"NODE_DEFAULT_BOXCOLOR": "#CCC",
@@ -36,6 +38,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#D4D4D4",
"WIDGET_OUTLINE_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#222",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -19,7 +19,9 @@
"litegraph_base": {
"NODE_TITLE_COLOR": "#fdf6e3",
"NODE_SELECTED_TITLE_COLOR": "#A9D400",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#657b83",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#094656",
"NODE_DEFAULT_BGCOLOR": "#073642",
"NODE_DEFAULT_BOXCOLOR": "#839496",
@@ -28,6 +30,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#002b36",
"WIDGET_OUTLINE_COLOR": "#839496",
"WIDGET_TEXT_COLOR": "#fdf6e3",

View File

@@ -71,8 +71,8 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -13,7 +14,6 @@ import {
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { render, screen } from '@/utils/test-utils'
import ComfyQueueButton from './ComfyQueueButton.vue'
@@ -78,40 +78,38 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(job)
}
const stubs = {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
function renderQueueButton() {
function createWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
return render(ComfyQueueButton, {
return mount(ComfyQueueButton, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
},
stubs
stubs: {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
}
})
}
describe('ComfyQueueButton', () => {
it('renders the batch count control before the run button', () => {
renderQueueButton()
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
const wrapper = createWrapper()
const controls = wrapper.get('.queue-button-group').element.children
expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit')
expect(controls[1]).toHaveAttribute('data-testid', 'queue-button')
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
})
it('keeps the run instant presentation while idle even with active jobs', async () => {
renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
@@ -119,27 +117,29 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
const queueButton = screen.getByTestId('queue-button')
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('switches to stop presentation when instant mode is armed', async () => {
renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
queueSettingsStore.mode = 'instant-running'
await nextTick()
const queueButton = screen.getByTestId('queue-button')
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton).toHaveTextContent('Stop Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'destructive')
expect(queueButton.text()).toContain('Stop Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('destructive')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
})
it('disarms instant mode without interrupting even when jobs are active', async () => {
const { user } = renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
@@ -148,26 +148,33 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
const queueButton = screen.getByTestId('queue-button')
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(commandStore.execute).not.toHaveBeenCalled()
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('activates instant running mode when queueing again', async () => {
const { user } = renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-running')

View File

@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
widgetName,
label: widget.label,
subLabel: node.title,
canRename: true
rename: () => promptRenameWidget(widget, node, t)
}
})
)
@@ -74,16 +74,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -244,7 +234,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
widgetName,
label,
subLabel,
canRename
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
@@ -252,7 +242,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="label ?? widgetName"
:sub-title="subLabel"
:can-rename="canRename"
:rename
:remove="
() =>
remove(
@@ -260,7 +250,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
</DraggableList>
</PropertiesAccordionItem>

View File

@@ -6,7 +6,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
@@ -45,7 +44,6 @@ const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(

View File

@@ -2,43 +2,31 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
const isEditing = ref(false)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { title, canRename, remove } = defineProps<{
const { rename, remove } = defineProps<{
title: string
subTitle?: string
canRename?: boolean
rename?: () => void
remove?: () => void
}>()
const emit = defineEmits<{
rename: [newName: string]
}>()
function onEditComplete(newName: string) {
isEditing.value = false
const trimmed = newName.trim()
if (trimmed && trimmed !== title) emit('rename', trimmed)
}
const entries = computed(() => {
const items = []
if (canRename)
if (rename)
items.push({
label: t('g.rename'),
command: () => setTimeout(() => (isEditing.value = true)),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
@@ -55,24 +43,13 @@ const entries = computed(() => {
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
data-testid="builder-io-item"
>
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
<EditableText
:model-value="title"
:is-editing="isEditing"
:input-attrs="{ class: 'p-1' }"
:class="
cn(
'drag-handle h-5 text-sm',
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
!isEditing && 'truncate'
)
"
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
<div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
class="drag-handle truncate text-sm"
data-testid="builder-io-item-title"
label-class="drag-handle"
label-type="div"
@dblclick="canRename && (isEditing = true)"
@edit="onEditComplete"
@cancel="isEditing = false"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"

View File

@@ -1,55 +0,0 @@
<template>
<NotificationPopup
v-if="appModeStore.showVueNodeSwitchPopup"
:title="$t('appBuilder.vueNodeSwitch.title')"
show-close
position="bottom-left"
@close="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.content') }}
<template #footer-start>
<label
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
>
<input
v-model="dontShowAgain"
type="checkbox"
class="accent-primary-background"
/>
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
</label>
</template>
<template #footer-end>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
</Button>
</template>
</NotificationPopup>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import NotificationPopup from '@/components/common/NotificationPopup.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
const appModeStore = useAppModeStore()
const settingStore = useSettingStore()
const dontShowAgain = ref(false)
function dismiss() {
if (dontShowAgain.value) {
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
}
appModeStore.showVueNodeSwitchPopup = false
}
</script>

View File

@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
const mountComponent = ({ closeOnScroll = false } = {}) =>
mount(ContextMenu, {
attachTo: document.body,
props: {
closeOnScroll,
contentClass: 'context-menu-content'
},
slots: {
default: '<button class="context-trigger" type="button">Trigger</button>',
content:
'<div class="context-menu-content-inner" role="menuitem">Action</div>'
}
})
async function openMenu() {
const trigger = document.body.querySelector('.context-trigger')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Context trigger element not found')
}
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
)
await waitForMenuUpdate()
}
const isMenuVisible = () =>
document.body.querySelector('.context-menu-content-inner') !== null
const waitForMenuUpdate = async () => {
await nextTick()
await nextTick()
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('opens from the slotted context-menu trigger', async () => {
const wrapper = mountComponent()
await openMenu()
expect(isMenuVisible()).toBe(true)
expect(
document.body.querySelectorAll('[role="menuitem"]').length
).toBeGreaterThan(0)
wrapper.unmount()
})
it('closes on scroll when enabled', async () => {
const wrapper = mountComponent({ closeOnScroll: true })
await openMenu()
window.dispatchEvent(new Event('scroll'))
await waitForMenuUpdate()
expect(isMenuVisible()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
injectContextMenuRootContext,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { defineComponent } from 'vue'
defineOptions({
inheritAttrs: false
})
const {
contentClass,
collisionPadding = 8,
closeOnScroll = false
} = defineProps<{
contentClass?: string
collisionPadding?: number
closeOnScroll?: boolean
}>()
const ContextMenuContentProvider = defineComponent({
name: 'ContextMenuContentProvider',
props: {
closeOnScroll: {
type: Boolean,
default: false
}
},
setup(providerProps, { slots }) {
const rootContext = injectContextMenuRootContext()
function closeMenu() {
rootContext.onOpenChange(false)
}
useEventListener(
window,
'scroll',
() => {
if (providerProps.closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
return () =>
slots.default?.({
close: closeMenu,
itemComponent: ContextMenuItem,
separatorComponent: ContextMenuSeparator
})
}
})
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<slot />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<ContextMenuContentProvider :close-on-scroll="closeOnScroll">
<template #default="{ close, itemComponent, separatorComponent }">
<slot
name="content"
:close="close"
:item-component="itemComponent"
:separator-component="separatorComponent"
/>
</template>
</ContextMenuContentProvider>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
@@ -18,7 +21,23 @@ defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const open = defineModel<boolean>('open', { default: false })
const {
entries,
icon,
to,
itemClass: itemProp,
contentClass: contentProp,
buttonSize,
buttonClass,
align,
showArrow = true,
side = 'bottom',
sideOffset = 5,
collisionPadding = 10,
closeOnScroll = false
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -26,6 +45,12 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
align?: 'start' | 'center' | 'end'
showArrow?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
collisionPadding?: number
closeOnScroll?: boolean
}>()
const itemClass = computed(() =>
@@ -41,10 +66,25 @@ const contentClass = computed(() =>
contentProp
)
)
function closeMenu() {
open.value = false
}
useEventListener(
window,
'scroll',
() => {
if (closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -55,22 +95,39 @@ const contentClass = computed(() =>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
:align
:side
:side-offset="sideOffset"
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
<slot
name="content"
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<slot
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
<DropdownMenuArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>

View File

@@ -1,8 +1,8 @@
<template>
<div class="editable-text">
<component :is="labelType" v-if="!isEditing" :class="labelClass">
<span v-if="!isEditing">
{{ modelValue }}
</component>
</span>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
v-else
@@ -35,15 +35,11 @@ import { nextTick, ref, watch } from 'vue'
const {
modelValue,
isEditing = false,
inputAttrs = {},
labelClass = '',
labelType = 'span'
inputAttrs = {}
} = defineProps<{
modelValue: string
isEditing?: boolean
inputAttrs?: Record<string, string>
labelClass?: string
labelType?: string
}>()
const emit = defineEmits(['edit', 'cancel'])

View File

@@ -0,0 +1,70 @@
<template>
<div :class="panelClass">
<template v-for="entry in entries" :key="entry.key">
<component
:is="separatorComponent"
v-if="entry.kind === 'divider'"
:class="separatorWrapperClass"
>
<div :class="separatorClass" />
</component>
<component
:is="itemComponent"
v-else
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
:variant="buttonVariant"
:size="buttonSize"
:class="buttonClass"
:disabled="entry.disabled"
>
<i v-if="entry.icon" :class="cn(entry.icon, iconClass)" />
<span :class="labelClass">{{ entry.label }}</span>
</Button>
</component>
</template>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { cn } from '@/utils/tailwindUtil'
const {
entries,
itemComponent,
separatorComponent,
panelClass,
separatorWrapperClass,
separatorClass,
buttonClass,
iconClass,
labelClass,
buttonVariant = 'secondary',
buttonSize = 'sm'
} = defineProps<{
entries: MenuEntry[]
itemComponent: Component
separatorComponent: Component
panelClass: string
separatorWrapperClass: string
separatorClass: string
buttonClass: string
iconClass: string
labelClass?: string
buttonVariant?: ButtonVariants['variant']
buttonSize?: ButtonVariants['size']
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
</script>

View File

@@ -1,78 +0,0 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import NotificationPopup from './NotificationPopup.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { g: { close: 'Close' } }
}
})
function mountPopup(
props: ComponentProps<typeof NotificationPopup> = {
title: 'Test'
},
slots: Record<string, string> = {}
) {
return mount(NotificationPopup, {
global: { plugins: [i18n] },
props,
slots
})
}
describe('NotificationPopup', () => {
it('renders title', () => {
const wrapper = mountPopup({ title: 'Hello World' })
expect(wrapper.text()).toContain('Hello World')
})
it('has role="status" for accessibility', () => {
const wrapper = mountPopup()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('renders subtitle when provided', () => {
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(wrapper.text()).toContain('v1.2.3')
})
it('renders icon when provided', () => {
const wrapper = mountPopup({
title: 'T',
icon: 'icon-[lucide--rocket]'
})
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('emits close when close button clicked', async () => {
const wrapper = mountPopup({ title: 'T', showClose: true })
await wrapper.find('[aria-label="Close"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('renders default slot content', () => {
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
expect(wrapper.text()).toContain('Body text here')
})
it('renders footer slots', () => {
const wrapper = mountPopup(
{ title: 'T' },
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
)
expect(wrapper.text()).toContain('Left side')
expect(wrapper.text()).toContain('Right side')
})
it('positions bottom-right when specified', () => {
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
const root = wrapper.find('[role="status"]')
expect(root.attributes('data-position')).toBe('bottom-right')
})
})

View File

@@ -1,87 +0,0 @@
<template>
<div
role="status"
:data-position="position"
:class="
cn(
'pointer-events-auto absolute z-1000 flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-interface',
position === 'bottom-left' && 'bottom-4 left-4',
position === 'bottom-right' && 'right-4 bottom-4'
)
"
>
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
<div class="flex items-center gap-4">
<div
v-if="icon"
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
>
<i :class="cn('size-4 text-white', icon)" />
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="text-sm leading-[1.429] font-normal text-base-foreground">
{{ title }}
</div>
<div
v-if="subtitle"
class="text-sm leading-[1.21] font-normal text-muted-foreground"
>
{{ subtitle }}
</div>
</div>
<Button
v-if="showClose"
class="size-6 shrink-0 self-start"
size="icon-sm"
variant="muted-textonly"
:aria-label="$t('g.close')"
@click="emit('close')"
>
<i class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<div
v-if="$slots.default"
class="min-h-0 flex-1 overflow-y-auto text-sm text-muted-foreground"
>
<slot />
</div>
</div>
<div
v-if="$slots['footer-start'] || $slots['footer-end']"
class="flex items-center justify-between px-4 pb-4"
>
<div>
<slot name="footer-start" />
</div>
<div class="flex items-center gap-4">
<slot name="footer-end" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
icon,
title,
subtitle,
showClose = false,
position = 'bottom-left'
} = defineProps<{
icon?: string
title: string
subtitle?: string
showClose?: boolean
position?: 'bottom-left' | 'bottom-right'
}>()
const emit = defineEmits<{
close: []
}>()
</script>

View File

@@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
balance: mockBalance.value,
isFetchingBalance: mockIsFetchingBalance.value
}))

View File

@@ -30,14 +30,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()

View File

@@ -1,60 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import WaveAudioPlayer from './WaveAudioPlayer.vue'
const meta: Meta<typeof WaveAudioPlayer> = {
title: 'Components/Audio/WaveAudioPlayer',
component: WaveAudioPlayer,
tags: ['autodocs'],
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
src: '/assets/audio/sample.wav',
barCount: 40,
height: 32
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
})
]
}
export const BottomAligned: Story = {
args: {
src: '/assets/audio/sample.wav',
barCount: 40,
height: 48,
align: 'bottom'
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
})
]
}
export const Expanded: Story = {
args: {
src: '/assets/audio/sample.wav',
variant: 'expanded',
barCount: 80,
height: 120
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[600px] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"><story /></div>'
})
]
}

View File

@@ -1,221 +0,0 @@
<template>
<!-- Compact: [] [waveform] [time] -->
<div
v-if="variant === 'compact'"
:class="
cn('flex w-full gap-2', align === 'center' ? 'items-center' : 'items-end')
"
@pointerdown.stop
@click.stop
>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
:loading="loading"
@click.stop="togglePlayPause"
>
<i
v-if="!isPlaying"
class="ml-0.5 icon-[lucide--play] size-3 text-base-foreground"
/>
<i v-else class="icon-[lucide--pause] size-3 text-base-foreground" />
</Button>
<div
:ref="(el) => (waveformRef = el as HTMLElement)"
:class="
cn(
'flex min-w-0 flex-1 cursor-pointer gap-px',
align === 'center' ? 'items-center' : 'items-end'
)
"
:style="{ height: height + 'px' }"
@click="handleWaveformClick"
>
<div
v-for="(bar, index) in bars"
:key="index"
:class="
cn(
'min-h-0.5 flex-1 rounded-full',
loading
? 'bg-muted-foreground/20'
: index <= playedBarIndex
? 'bg-base-foreground'
: 'bg-muted-foreground/40'
)
"
:style="{ height: (bar.height / 100) * height + 'px' }"
/>
</div>
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">
{{ formattedCurrentTime }} / {{ formattedDuration }}
</span>
</div>
<!-- Expanded: waveform / progress bar + times / transport -->
<div v-else class="flex w-full flex-col gap-4" @pointerdown.stop @click.stop>
<div
class="flex w-full items-center gap-0.5"
:style="{ height: height + 'px' }"
>
<div
v-for="(bar, index) in bars"
:key="index"
:class="
cn(
'min-h-0.5 flex-1 rounded-full',
loading ? 'bg-muted-foreground/20' : 'bg-base-foreground'
)
"
:style="{ height: (bar.height / 100) * height + 'px' }"
/>
</div>
<div class="flex flex-col gap-1">
<div
ref="progressRef"
class="relative h-1 w-full cursor-pointer rounded-full bg-muted-foreground/20"
@click="handleProgressClick"
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-base-foreground"
:style="{ width: progressRatio + '%' }"
/>
</div>
<div
class="flex justify-between text-xs text-muted-foreground tabular-nums"
>
<span>{{ formattedCurrentTime }}</span>
<span>{{ formattedDuration }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<div class="w-20" />
<div class="flex flex-1 items-center justify-center gap-2">
<Button
variant="textonly"
size="icon-sm"
class="size-8 rounded-full"
:aria-label="$t('g.skipToStart')"
:disabled="loading"
@click="seekToStart"
>
<i class="icon-[lucide--skip-back] size-4 text-base-foreground" />
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-10 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
:loading="loading"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="ml-0.5 icon-[lucide--play] size-5 text-base-foreground"
/>
<i v-else class="icon-[lucide--pause] size-5 text-base-foreground" />
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-8 rounded-full"
:aria-label="$t('g.skipToEnd')"
:disabled="loading"
@click="seekToEnd"
>
<i class="icon-[lucide--skip-forward] size-4 text-base-foreground" />
</Button>
</div>
<div class="flex w-20 items-center gap-1">
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 rounded-full"
:aria-label="$t('g.volume')"
:disabled="loading"
@click="toggleMute"
>
<i :class="cn(volumeIcon, 'size-4 text-base-foreground')" />
</Button>
<Slider
:model-value="[volume * 100]"
:min="0"
:max="100"
:step="1"
class="flex-1"
@update:model-value="(v) => (volume = (v?.[0] ?? 100) / 100)"
/>
</div>
</div>
</div>
<audio
:ref="(el) => (audioRef = el as HTMLAudioElement)"
:src="audioSrc"
preload="metadata"
class="hidden"
/>
</template>
<script setup lang="ts">
import { ref, toRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useWaveAudioPlayer } from '@/composables/useWaveAudioPlayer'
import { cn } from '@/utils/tailwindUtil'
const {
src,
barCount = 40,
height = 32,
align = 'center',
variant = 'compact'
} = defineProps<{
src: string
barCount?: number
height?: number
align?: 'center' | 'bottom'
variant?: 'compact' | 'expanded'
}>()
const progressRef = ref<HTMLElement>()
const {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying,
playedBarIndex,
progressRatio,
formattedCurrentTime,
formattedDuration,
togglePlayPause,
seekToStart,
seekToEnd,
volume,
volumeIcon,
toggleMute,
seekToRatio,
handleWaveformClick
} = useWaveAudioPlayer({
src: toRef(() => src),
barCount
})
function handleProgressClick(event: MouseEvent) {
if (!progressRef.value) return
const rect = progressRef.value.getBoundingClientRect()
seekToRatio((event.clientX - rect.left) / rect.width)
}
</script>

View File

@@ -147,7 +147,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
@@ -167,7 +167,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
@@ -186,13 +186,13 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGithub()) {
onSuccess()
}
}

View File

@@ -156,7 +156,7 @@ import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -171,7 +171,7 @@ const { isInsufficientCredits = false } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()

View File

@@ -21,10 +21,10 @@ import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
const authActions = useAuthActions()
const authActions = useFirebaseAuthActions()
const loading = ref(false)
const { onSuccess } = defineProps<{

View File

@@ -19,7 +19,10 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -116,12 +116,12 @@ import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
@@ -133,8 +133,8 @@ interface CreditHistoryItemData {
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()

View File

@@ -18,8 +18,8 @@ import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))

View File

@@ -100,9 +100,9 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>

View File

@@ -35,15 +35,15 @@ vi.mock('firebase/auth', () => ({
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}

View File

@@ -88,14 +88,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useAuthStore()
const authActions = useAuthActions()
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
@@ -127,6 +127,6 @@ const handleForgotPassword = async (
document.getElementById(emailInputId)?.focus?.()
return
}
await authActions.sendPasswordReset(email)
await firebaseAuthActions.sendPasswordReset(email)
}
</script>

View File

@@ -54,12 +54,12 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const emit = defineEmits<{

View File

@@ -49,12 +49,7 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -97,7 +97,6 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
canvasStore.canvas to be initialized. -->
@@ -129,7 +128,6 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -195,7 +193,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
@@ -456,9 +453,8 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -578,18 +574,6 @@ onMounted(async () => {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -84,9 +84,7 @@ watch(
pos: group.pos,
size: [group.size[0], group.titleHeight]
})
inputFontStyle.value = {
fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px`
}
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
} else if (target instanceof LGraphNode) {
const node = target
const [x, y] = node.getBounding()

View File

@@ -17,7 +17,11 @@
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>

View File

@@ -1,60 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,215 +1,207 @@
<template>
<ComboboxRoot
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
:class="
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ selectedCount }}
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -224,9 +216,22 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<SelectOption[]>({
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -234,16 +239,15 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
const fuseOptions: UseFuseOptions<SelectOption> = {
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -252,20 +256,23 @@ const fuseOptions: UseFuseOptions<SelectOption> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, () => options, fuseOptions)
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return options
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: SelectOption }) => result.item
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: SelectOption) => result.value === item.value)
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,12 +1,21 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -14,107 +23,121 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -129,12 +152,16 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -142,8 +169,6 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -156,8 +181,26 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
</script>

View File

@@ -14,11 +14,6 @@ const meta: Meta<typeof Loader> = {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Spinner size: sm (16px), md (32px), lg (48px)'
},
variant: {
control: 'select',
options: ['loader', 'loader-circle'],
description: 'The type of loader displayed'
}
}
}

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
useJobMenu: () => ({ getJobMenuEntries: () => [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -56,8 +52,7 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
JobAssetsList: JobAssetsListStub
}
}
})

View File

@@ -23,36 +23,28 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@menu-action="onJobMenuAction"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobAssetsList from './job/JobAssetsList.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
@@ -78,13 +70,10 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
const { getJobMenuEntries } = useJobMenu(undefined, (item) =>
emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
@@ -95,14 +84,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -3,11 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -32,43 +37,34 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
): NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']> =>
({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const createTaskRef = (
preview?: NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
@@ -82,7 +78,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
props: {
displayedJobGroups,
getMenuEntries: () => []
},
global: {
stubs: {
teleport: true,
@@ -147,7 +146,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
@@ -164,7 +163,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
@@ -179,7 +178,7 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const wrapper = mountJobAssetsList([job])

View File

@@ -15,64 +15,98 @@
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<ContextMenu
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="shouldShowActionsMenu(job.id)" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<DropdownMenu
:open="openActionsJobId === job.id"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<template #button>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template
#content="{ close, itemComponent, separatorComponent }"
>
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</AssetsListItem>
</ContextMenu>
</div>
</div>
</div>
@@ -80,7 +114,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -100,9 +134,13 @@
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
@@ -110,17 +148,32 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'viewItem', item: JobListItem): void
}>()
const jobMenuPanelProps = {
panelClass:
'job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter',
separatorWrapperClass: 'px-2 py-1',
separatorClass: 'h-px bg-interface-stroke',
buttonVariant: 'textonly',
buttonClass:
'w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover',
iconClass: 'block size-4 shrink-0 leading-none text-text-secondary'
} as const
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const openActionsJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const {
@@ -188,6 +241,26 @@ function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
function shouldShowActionsMenu(jobId: string) {
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
}
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
if (isOpen) {
openActionsJobId.value = jobId
return
}
if (openActionsJobId.value === jobId) {
openActionsJobId.value = null
}
}
function onMenuAction(entry: MenuActionEntry, close: () => void) {
close()
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}

View File

@@ -1,195 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const popoverStub = defineComponent({
name: 'Popover',
emits: ['show', 'hide'],
data() {
return {
visible: false,
container: null as HTMLElement | null,
eventTarget: null as EventTarget | null,
target: null as EventTarget | null
}
},
mounted() {
this.container = this.$refs.container as HTMLElement | null
},
updated() {
this.container = this.$refs.container as HTMLElement | null
},
methods: {
toggle(event: Event, target?: EventTarget | null) {
if (this.visible) {
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
this.visible = true
this.eventTarget = event.currentTarget
this.target = target ?? event.currentTarget
this.$emit('show')
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
})
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</div>
`
}
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
},
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
currentTarget,
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
}
])
await openMenu(wrapper)
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,118 +0,0 @@
<template>
<Popover
ref="jobItemPopoverRef"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
@show="isVisible = true"
@hide="onHide"
>
<div
ref="contentRef"
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<Button
v-else
class="w-full justify-start bg-transparent"
variant="textonly"
size="sm"
:aria-label="entry.label"
:disabled="entry.disabled"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</Button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
type PopoverHandle = {
hide: () => void
show: (event: Event, target?: EventTarget | null) => void
}
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isVisible = ref(false)
const openedByClick = ref(false)
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => contentRef.value,
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
onDismiss: hide
})
async function open(event: Event) {
const trigger =
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const isSameClickTrigger =
event.type === 'click' && trigger === triggerRef.value && isVisible.value
if (isSameClickTrigger) {
hide()
return
}
openedByClick.value = event.type === 'click'
triggerRef.value = trigger
if (isVisible.value) {
hide()
await nextTick()
}
jobItemPopoverRef.value?.show(event, trigger)
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onHide() {
isVisible.value = false
openedByClick.value = false
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -9,7 +9,7 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -23,7 +23,7 @@
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`

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