Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
69bc33eef5 fix: Add unit tests for resolveSubgraphInputLink (#9293)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:04:25 +01:00
281 changed files with 1977 additions and 7297 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: lts/*
cache: 'pnpm' cache: 'pnpm'
- name: Update electron types - name: Update electron types

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: lts/*
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: lts/*
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

View File

@@ -26,7 +26,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

View File

@@ -13,8 +13,6 @@ on:
branches: branches:
- 'cloud/*' - 'cloud/*'
- 'main' - 'main'
pull_request:
types: [labeled]
workflow_dispatch: workflow_dispatch:
permissions: {} permissions: {}
@@ -25,31 +23,16 @@ concurrency:
jobs: jobs:
dispatch: dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo. # Fork guard: prevent forks from dispatching to the cloud repo
# For pull_request events, only dispatch when the 'preview' label is added. if: github.repository == 'Comfy-Org/ComfyUI_frontend'
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Build client payload - name: Build client payload
id: payload id: payload
env:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: | run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
fi
payload="$(jq -nc \ payload="$(jq -nc \
--arg ref "${REF}" \ --arg ref "${GITHUB_SHA}" \
--arg branch "${BRANCH}" \ --arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')" '{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}" echo "json=${payload}" >> "${GITHUB_OUTPUT}"

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: '20'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies for analysis tools - name: Install dependencies for analysis tools

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: 22
- name: Download PR metadata - name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: '24.x'
- name: Read desktop-ui version - name: Read desktop-ui version
id: get_version id: get_version

View File

@@ -91,7 +91,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: '24.x'
cache: 'pnpm' cache: 'pnpm'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: 'frontend/.nvmrc' node-version: lts/*
- name: Install dependencies - name: Install dependencies
working-directory: frontend working-directory: frontend

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
- name: Check version bump type - name: Check version bump type
id: check_version id: check_version

View File

@@ -26,7 +26,7 @@ jobs:
version: 10 version: 10
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
- name: Get current version - name: Get current version

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org

View File

@@ -22,7 +22,7 @@ jobs:
version: 10 version: 10
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: 'lts/*'
cache: 'pnpm' cache: 'pnpm'
- name: Get current version - name: Get current version

View File

@@ -149,7 +149,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: lts/*
- name: Bump version - name: Bump version
id: bump-version id: bump-version

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: '24.x'
cache: 'pnpm' cache: 'pnpm'
- name: Bump desktop-ui version - name: Bump desktop-ui version

View File

@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version-file: '.nvmrc' node-version: '20'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies for analysis tools - name: Install dependencies for analysis tools

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack ### Prerequisites & Technology Stack
- **Required Software**: - **Required Software**:
- Node.js (see `.nvmrc`, currently v24) and pnpm - Node.js (v24) and pnpm
- Git for version control - Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`) - A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

View File

@@ -1,7 +1,10 @@
<template> <template>
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900"> <div
<div class="p-terminal size-full rounded-none p-2"> ref="rootEl"
<div ref="terminalEl" class="terminal-host h-full" /> class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div> </div>
<Button <Button
v-tooltip.left="{ v-tooltip.left="{
@@ -13,7 +16,7 @@
size="small" size="small"
:class=" :class="
cn('absolute top-2 right-8 transition-opacity', { cn('absolute top-2 right-8 transition-opacity', {
'pointer-events-none opacity-0 select-none': !isHovered 'opacity-0 pointer-events-none select-none': !isHovered
}) })
" "
:aria-label="tooltipText" :aria-label="tooltipText"

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-8 select-none"> <div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section --> <!-- Installation Path Section -->
<div class="flex grow flex-col gap-6 text-neutral-300"> <div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100"> <h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }} {{ $t('install.locationPicker.title') }}
</h2> </h2>
<p class="px-12 text-center text-neutral-400"> <p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }} {{ $t('install.locationPicker.subtitle') }}
</p> </p>
@@ -15,7 +15,7 @@
<InputText <InputText
v-model="installPath" v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')" :placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 border-neutral-700 bg-neutral-800/50 text-neutral-200 placeholder:text-neutral-500" class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }" :class="{ 'p-invalid': pathError }"
@update:model-value="validatePath" @update:model-value="validatePath"
@focus="onFocus" @focus="onFocus"
@@ -23,7 +23,7 @@
<Button <Button
icon="pi pi-folder-open" icon="pi pi-folder-open"
severity="secondary" severity="secondary"
class="border-0 bg-neutral-700 hover:bg-neutral-600" class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath" @click="browsePath"
/> />
</div> </div>
@@ -33,7 +33,7 @@
<Message <Message
v-if="pathError" v-if="pathError"
severity="error" severity="error"
class="w-full whitespace-pre-line" class="whitespace-pre-line w-full"
> >
{{ pathError }} {{ pathError }}
</Message> </Message>

View File

@@ -26,7 +26,7 @@
<img <img
v-if="task.headerImg" v-if="task.headerImg"
:src="task.headerImg" :src="task.headerImg"
class="size-full object-contain px-4 pt-4 opacity-25" class="h-full w-full object-contain px-4 pt-4 opacity-25"
/> />
</template> </template>
<template #title> <template #title>
@@ -52,7 +52,7 @@
<i <i
v-if="!isLoading && runner.state === 'OK'" v-if="!isLoading && runner.state === 'OK'"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 z-10 col-span-full row-span-full text-[4rem] text-green-500 opacity-100 transition-opacity [text-shadow:0.25rem_0_0.5rem_black] group-hover/task-card:opacity-20" class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
/> />
</div> </div>
</template> </template>

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0"> <template v-if="filter.tasks.length === 0">
<!-- Empty filter --> <!-- Empty filter -->
<Divider /> <Divider />
<p class="w-full text-center text-neutral-400"> <p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }} {{ $t('maintenance.allOk') }}
</p> </p>
</template> </template>
@@ -25,7 +25,7 @@
<!-- Display: Cards --> <!-- Display: Cards -->
<template v-else> <template v-else>
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8"> <div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard <TaskCard
v-for="task in filter.tasks" v-for="task in filter.tasks"
:key="task.id" :key="task.id"
@@ -45,8 +45,7 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup' import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import { useI18n } from 'vue-i18n' import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore' import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { import type {
MaintenanceFilter, MaintenanceFilter,
@@ -56,7 +55,6 @@ import type {
import TaskCard from './TaskCard.vue' import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue' import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast() const toast = useToast()
const confirm = useConfirm() const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore() const taskStore = useMaintenanceTaskStore()
@@ -82,7 +80,8 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('maintenance.error.toastTitle'), summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription') detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
}) })
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex size-full flex-col justify-between rounded-lg p-6"> <div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="m-0 font-inter text-xl font-semibold italic"> <h1 class="font-inter font-semibold text-xl m-0 italic">
{{ $t(`desktopDialogs.${id}.title`, title) }} {{ $t(`desktopDialogs.${id}.title`, title) }}
</h1> </h1>
<p class="whitespace-pre-wrap"> <p class="whitespace-pre-wrap">

View File

@@ -1,7 +1,7 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<div <div
class="grid h-screen w-screen items-center justify-around overflow-y-auto" class="h-screen w-screen grid items-center justify-around overflow-y-auto"
> >
<div class="relative m-8 text-center"> <div class="relative m-8 text-center">
<!-- Header --> <!-- Header -->
@@ -13,7 +13,7 @@
<span>{{ $t('desktopUpdate.description') }}</span> <span>{{ $t('desktopUpdate.description') }}</span>
</div> </div>
<ProgressSpinner class="m-8 size-48" /> <ProgressSpinner class="m-8 w-48 h-48" />
<!-- Console button --> <!-- Console button -->
<Button <Button

View File

@@ -1,10 +1,10 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<!-- Fixed height container with flexbox layout for proper content management --> <!-- Fixed height container with flexbox layout for proper content management -->
<div class="flex size-full flex-col"> <div class="w-full h-full flex flex-col">
<Stepper <Stepper
v-model:value="currentStep" v-model:value="currentStep"
class="flex h-full flex-col" class="flex flex-col h-full"
@update:value="handleStepChange" @update:value="handleStepChange"
> >
<!-- Main content area that grows to fill available space --> <!-- Main content area that grows to fill available space -->
@@ -37,7 +37,7 @@
<!-- Install footer with navigation --> <!-- Install footer with navigation -->
<InstallFooter <InstallFooter
class="mx-auto my-6 w-full max-w-2xl" class="w-full max-w-2xl my-6 mx-auto"
:current-step :current-step
:can-proceed :can-proceed
:disable-location-step="noGpu" :disable-location-step="noGpu"

View File

@@ -1,21 +1,21 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<div <div
class="dark-theme grid h-screen min-h-full w-screen min-w-full justify-around overflow-y-auto bg-neutral-900 font-sans text-neutral-300" class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
> >
<div class="relative m-8 w-screen max-w-(--breakpoint-sm)"> <div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
<!-- Header --> <!-- Header -->
<h1 class="backspan pi-wrench text-4xl font-bold"> <h1 class="backspan pi-wrench text-4xl font-bold">
{{ t('maintenance.title') }} {{ t('maintenance.title') }}
</h1> </h1>
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex w-full flex-wrap items-center gap-4"> <div class="w-full flex flex-wrap gap-4 items-center">
<span class="grow"> <span class="grow">
{{ t('maintenance.status') }}: {{ t('maintenance.status') }}:
<StatusTag :refreshing="isRefreshing" :error="anyErrors" /> <StatusTag :refreshing="isRefreshing" :error="anyErrors" />
</span> </span>
<div class="flex items-center gap-4"> <div class="flex gap-4 items-center">
<SelectButton <SelectButton
v-model="displayAsList" v-model="displayAsList"
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]" :options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
@@ -56,10 +56,10 @@
:value="t('icon.exclamation-triangle')" :value="t('icon.exclamation-triangle')"
/> />
<span> <span>
<strong class="mb-1 block"> <strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }} {{ t('maintenance.unsafeMigration.title') }}
</strong> </strong>
<span class="mb-1 block"> <span class="block mb-1">
{{ unsafeReasonText }} {{ unsafeReasonText }}
</span> </span>
<span class="block text-sm text-neutral-400"> <span class="block text-sm text-neutral-400">
@@ -71,13 +71,13 @@
<!-- Tasks --> <!-- Tasks -->
<TaskListPanel <TaskListPanel
class="border-x-0 border-y border-solid border-neutral-700" class="border-neutral-700 border-solid border-x-0 border-y"
:filter :filter
:display-as-list :display-as-list
/> />
<!-- Actions --> <!-- Actions -->
<div class="flex flex-row justify-between gap-4"> <div class="flex justify-between gap-4 flex-row">
<Button <Button
:label="t('maintenance.consoleLogs')" :label="t('maintenance.consoleLogs')"
icon="pi pi-desktop" icon="pi pi-desktop"
@@ -189,7 +189,8 @@ const completeValidation = async () => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: t('maintenance.error.cannotContinue') detail: t('maintenance.error.cannotContinue'),
life: 5_000
}) })
} }
} }

View File

@@ -1,8 +1,8 @@
<template> <template>
<BaseViewTemplate dark hide-language-selector> <BaseViewTemplate dark hide-language-selector>
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16"> <div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div <div
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg" class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
> >
<h2 class="text-3xl font-semibold text-neutral-100"> <h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }} {{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a <a
href="https://comfy.org/privacy" href="https://comfy.org/privacy"
target="_blank" target="_blank"
class="text-blue-400 underline hover:text-blue-300" class="text-blue-400 hover:text-blue-300 underline"
> >
{{ $t('install.privacyPolicy') }} </a {{ $t('install.privacyPolicy') }} </a
>. >.
@@ -33,7 +33,7 @@
}} }}
</span> </span>
</div> </div>
<div class="flex justify-end pt-6"> <div class="flex pt-6 justify-end">
<Button <Button
:label="$t('g.ok')" :label="$t('g.ok')"
icon="pi pi-check" icon="pi pi-check"
@@ -72,7 +72,8 @@ const updateConsent = async () => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('install.settings.errorUpdatingConsent'), summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail') detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
}) })
} finally { } finally {
isUpdating.value = false isUpdating.value = false

View File

@@ -9,7 +9,7 @@
/> />
<div class="no-drag sad-text flex items-center"> <div class="no-drag sad-text flex items-center">
<div class="flex min-w-110 flex-col gap-8 p-8"> <div class="flex flex-col gap-8 p-8 min-w-110">
<!-- Header --> <!-- Header -->
<h1 class="text-4xl font-bold text-red-500"> <h1 class="text-4xl font-bold text-red-500">
{{ $t('notSupported.title') }} {{ $t('notSupported.title') }}
@@ -20,7 +20,7 @@
<p class="text-xl"> <p class="text-xl">
{{ $t('notSupported.message') }} {{ $t('notSupported.message') }}
</p> </p>
<ul class="list-inside list-disc space-y-1 text-neutral-800"> <ul class="list-disc list-inside space-y-1 text-neutral-800">
<li>{{ $t('notSupported.supportedDevices.macos') }}</li> <li>{{ $t('notSupported.supportedDevices.macos') }}</li>
<li>{{ $t('notSupported.supportedDevices.windows') }}</li> <li>{{ $t('notSupported.supportedDevices.windows') }}</li>
</ul> </ul>

View File

@@ -2,14 +2,14 @@
<BaseViewTemplate dark> <BaseViewTemplate dark>
<div class="relative min-h-screen"> <div class="relative min-h-screen">
<!-- Terminal Background Layer (always visible during loading) --> <!-- Terminal Background Layer (always visible during loading) -->
<div v-if="!isError" class="fixed inset-0 z-0 overflow-hidden"> <div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
<div class="size-full"> <div class="h-full w-full">
<BaseTerminal @created="terminalCreated" /> <BaseTerminal @created="terminalCreated" />
</div> </div>
</div> </div>
<!-- Semi-transparent overlay --> <!-- Semi-transparent overlay -->
<div v-if="!isError" class="fixed inset-0 z-5 bg-neutral-900/80"></div> <div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
<!-- Smooth radial gradient overlay --> <!-- Smooth radial gradient overlay -->
<div <div
@@ -45,9 +45,9 @@
<!-- Error Section (positioned at bottom) --> <!-- Error Section (positioned at bottom) -->
<div <div
v-if="isError" v-if="isError"
class="absolute inset-x-0 bottom-20 flex flex-col items-center gap-4" class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
> >
<div class="flex justify-center gap-4"> <div class="flex gap-4 justify-center">
<Button <Button
icon="pi pi-flag" icon="pi pi-flag"
:label="$t('serverStart.reportIssue')" :label="$t('serverStart.reportIssue')"
@@ -71,10 +71,10 @@
<!-- Terminal Output (positioned at bottom when manually toggled in error state) --> <!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
<div <div
v-if="terminalVisible && isError" v-if="terminalVisible && isError"
class="absolute inset-x-4 bottom-4 z-10 mx-auto max-w-4xl" class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
> >
<div <div
class="h-[300px] rounded-lg border border-neutral-700 bg-neutral-900/95 p-4" class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
> >
<BaseTerminal @created="terminalCreated" /> <BaseTerminal @created="terminalCreated" />
</div> </div>

View File

@@ -27,8 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites ### Node.js & Playwright Prerequisites
Ensure you have the Node.js version from `.nvmrc` installed (currently v24). Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
Then, set up the Chromium test driver:
```bash ```bash
pnpm exec playwright install chromium --with-deps pnpm exec playwright install chromium --with-deps

View File

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

View File

@@ -206,7 +206,9 @@ export class ComfyPage {
this.widgetTextBox = page.getByPlaceholder('text').nth(1) this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' }) this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' }) this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.runButton = page.getByTestId(TestIds.topbar.queueButton) this.runButton = page
.getByTestId(TestIds.topbar.queueButton)
.getByRole('button', { name: 'Run' })
this.workflowUploadInput = page.locator('#comfy-file-input') this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page) this.searchBox = new ComfyNodeSearchBox(page)
@@ -430,10 +432,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.VueNodes.AutoScaleLayout': false, 'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or // Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies // may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true, 'Comfy.VersionCompatibility.DisableWarnings': true
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
}) })
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View File

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

View File

@@ -33,7 +33,6 @@ export const TestIds = {
}, },
topbar: { topbar: {
queueButton: 'queue-button', queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button' saveButton: 'save-workflow-button'
}, },
nodeLibrary: { nodeLibrary: {

View File

@@ -29,10 +29,8 @@ class ComfyQueueButton {
public readonly dropdownButton: Locator public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) { constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton) this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
this.primaryButton = this.root this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = actionbar.root.getByTestId( this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
TestIds.topbar.queueModeMenuTrigger
)
} }
public async toggleOptions() { public async toggleOptions() {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -46,9 +46,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml', '.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR // Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue', 'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in stacked PR (#9647)
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
'src/components/ui/color-picker/ColorPickerSlider.vue',
// Agent review check config, not part of the build // Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js' '.agents/checks/eslint.strict.config.js'
], ],

View File

@@ -1,6 +1,6 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"version": "1.42.2", "version": "1.41.13",
"private": true, "private": true,
"description": "Official front-end implementation of ComfyUI", "description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -195,9 +195,6 @@
"zip-dir": "^2.0.0", "zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:" "zod-to-json-schema": "catalog:"
}, },
"engines": {
"node": "24.x"
},
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "catalog:" "vite": "catalog:"

View File

@@ -166,22 +166,13 @@ describe('TopMenuSection', () => {
}) })
describe('authentication state', () => { describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => { describe('when user is logged in', () => {
beforeEach(() => { beforeEach(() => {
mockData.isLoggedIn = true mockData.isLoggedIn = true
}) })
it('should display CurrentUserButton and not display LoginButton', () => { it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper() const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true) expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false) expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
}) })
@@ -195,7 +186,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => { describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => { it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true mockData.isDesktop = true
const wrapper = createLegacyTabBarWrapper() const wrapper = createWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true) expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false) expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
}) })
@@ -203,7 +194,7 @@ describe('TopMenuSection', () => {
describe('on web platform', () => { describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => { it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper() const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false) expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false) expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
}) })

View File

@@ -183,7 +183,7 @@ const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value () => isActionbarEnabled.value && !isActionbarDocked.value
) )
const isIntegratedTabBar = computed( const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy' () => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
) )
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } = const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags() useQueueFeatureFlags()

View File

@@ -1,98 +0,0 @@
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'
import { useQueueSettingsStore } from '@/stores/queueStore'
import BatchCountEdit from './BatchCountEdit.vue'
const maxBatchCount = 16
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (settingId: string) =>
settingId === 'Comfy.QueueButton.BatchCountLimit' ? maxBatchCount : 1
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
increment: 'Increment',
decrement: 'Decrement'
},
menu: {
batchCount: 'Batch Count'
}
}
}
})
function createWrapper(initialBatchCount = 1) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
queueSettingsStore: {
batchCount: initialBatchCount
}
}
})
const wrapper = mount(BatchCountEdit, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
}
}
})
const queueSettingsStore = useQueueSettingsStore()
return { wrapper, queueSettingsStore }
}
describe('BatchCountEdit', () => {
it('doubles the current batch count when increment is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(3)
await wrapper.get('button[aria-label="Increment"]').trigger('click')
expect(queueSettingsStore.batchCount).toBe(6)
})
it('halves the current batch count when decrement is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(9)
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
expect(queueSettingsStore.batchCount).toBe(4)
})
it('clamps typed values to queue limits on blur', async () => {
const { wrapper, queueSettingsStore } = createWrapper(2)
const input = wrapper.get('input')
await input.setValue('999')
await input.trigger('blur')
await nextTick()
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
expect((input.element as HTMLInputElement).value).toBe(
String(maxBatchCount)
)
await input.setValue('0')
await input.trigger('blur')
await nextTick()
expect(queueSettingsStore.batchCount).toBe(1)
expect((input.element as HTMLInputElement).value).toBe('1')
})
})

View File

@@ -1,129 +1,71 @@
<template> <template>
<div <div
v-tooltip.bottom="{ v-tooltip.bottom="{
value: t('menu.batchCount'), value: $t('menu.batchCount'),
showDelay: 600 showDelay: 600
}" }"
class="batch-count h-full" class="batch-count"
:aria-label="t('menu.batchCount')" :aria-label="$t('menu.batchCount')"
> >
<div <InputNumber
class="flex h-full w-14 overflow-hidden rounded-l-lg bg-secondary-background" v-model="batchCount"
> class="w-14"
<input :min="minQueueCount"
ref="batchCountInputRef" :max="maxQueueCount"
v-model="batchCountInput" fluid
type="text" show-buttons
inputmode="numeric" :pt="{
:aria-label="t('menu.batchCount')" incrementButton: {
:class="inputClass" class: 'w-6',
@focus="onInputFocus" onmousedown: () => {
@input="onInput" handleClick(true)
@blur="onInputBlur" }
@keydown.enter.prevent="onInputEnter" },
/> decrementButton: {
<div class="flex h-full w-6 flex-col"> class: 'w-6',
<Button onmousedown: () => {
variant="secondary" handleClick(false)
size="unset" }
:aria-label="t('g.increment')" }
:class="cn(stepButtonClass, incrementButtonClass)" }"
:disabled="isIncrementDisabled" />
@click="incrementBatchCount"
>
<TinyChevronIcon rotate-up />
</Button>
<Button
variant="secondary"
size="unset"
:aria-label="t('g.decrement')"
:class="cn(stepButtonClass, decrementButtonClass)"
:disabled="isDecrementDisabled"
@click="decrementBatchCount"
>
<TinyChevronIcon />
</Button>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue' import InputNumber from 'primevue/inputnumber'
import { useI18n } from 'vue-i18n' import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import TinyChevronIcon from './TinyChevronIcon.vue'
const { t } = useI18n()
const queueSettingsStore = useQueueSettingsStore() const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore) const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const settingStore = useSettingStore() const settingStore = useSettingStore()
const minQueueCount = 1
const maxQueueCount = computed(() => const maxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit') settingStore.get('Comfy.QueueButton.BatchCountLimit')
) )
const batchCountInputRef = ref<HTMLInputElement | null>(null) const handleClick = (increment: boolean) => {
const batchCountInput = ref(String(batchCount.value)) let newCount: number
const isEditing = ref(false) if (increment) {
const originalCount = batchCount.value - 1
const isIncrementDisabled = computed( newCount = Math.min(originalCount * 2, maxQueueCount.value)
() => batchCount.value >= maxQueueCount.value } else {
) const originalCount = batchCount.value + 1
const isDecrementDisabled = computed(() => batchCount.value <= minQueueCount) newCount = Math.floor(originalCount / 2)
const inputClass =
'h-full min-w-0 flex-1 border-none bg-secondary-background pl-1 pr-0 text-center text-sm font-normal tabular-nums text-base-foreground outline-none'
const stepButtonClass =
'h-1/2 w-full rounded-none border-none p-0 text-muted-foreground hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50'
const incrementButtonClass = 'rounded-tr-none border-b border-border-subtle'
const decrementButtonClass = 'rounded-br-none'
watch(batchCount, (nextBatchCount) => {
if (!isEditing.value) {
batchCountInput.value = String(nextBatchCount)
} }
})
const clampBatchCount = (nextBatchCount: number): number => batchCount.value = newCount
Math.min(Math.max(nextBatchCount, minQueueCount), maxQueueCount.value)
const setBatchCount = (nextBatchCount: number) => {
batchCount.value = clampBatchCount(nextBatchCount)
batchCountInput.value = String(batchCount.value)
}
const incrementBatchCount = () => {
setBatchCount(batchCount.value * 2)
}
const decrementBatchCount = () => {
setBatchCount(Math.floor(batchCount.value / 2))
}
const onInputFocus = () => {
isEditing.value = true
}
const onInput = (event: Event) => {
const input = event.target as HTMLInputElement
batchCountInput.value = input.value.replace(/[^0-9]/g, '')
}
const onInputBlur = () => {
isEditing.value = false
const parsedInput = Number.parseInt(batchCountInput.value, 10)
setBatchCount(Number.isNaN(parsedInput) ? minQueueCount : parsedInput)
}
const onInputEnter = () => {
batchCountInputRef.value?.blur()
} }
</script> </script>
<style scoped>
:deep(.p-inputtext) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import type { import type {
@@ -41,9 +41,28 @@ vi.mock('@/stores/workspaceStore', () => ({
}) })
})) }))
const BatchCountEditStub = { const SplitButtonStub = defineComponent({
template: '<div data-testid="batch-count-edit" />' name: 'SplitButton',
} props: {
label: {
type: String,
default: ''
},
severity: {
type: String,
default: 'primary'
}
},
template: `
<button
data-testid="split-button"
:data-label="label"
:data-severity="severity"
>
<slot name="icon" />
</button>
`
})
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
@@ -88,26 +107,14 @@ function createWrapper() {
tooltip: () => {} tooltip: () => {}
}, },
stubs: { stubs: {
BatchCountEdit: BatchCountEditStub, SplitButton: SplitButtonStub,
DropdownMenuRoot: { template: '<div><slot /></div>' }, BatchCountEdit: true
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
} }
} }
}) })
} }
describe('ComfyQueueButton', () => { describe('ComfyQueueButton', () => {
it('renders the batch count control before the run button', () => {
const wrapper = createWrapper()
const controls = wrapper.get('.queue-button-group').element.children
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 () => { it('keeps the run instant presentation while idle even with active jobs', async () => {
const wrapper = createWrapper() const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore() const queueSettingsStore = useQueueSettingsStore()
@@ -117,10 +124,10 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')] queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick() await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]') const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton.text()).toContain('Run (Instant)') expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary') expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
}) })
@@ -131,10 +138,10 @@ describe('ComfyQueueButton', () => {
queueSettingsStore.mode = 'instant-running' queueSettingsStore.mode = 'instant-running'
await nextTick() await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]') const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton.text()).toContain('Stop Run (Instant)') expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('destructive') expect(splitButton.attributes('data-severity')).toBe('danger')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true) expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
}) })
@@ -152,17 +159,19 @@ describe('ComfyQueueButton', () => {
await nextTick() await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle') expect(queueSettingsStore.mode).toBe('instant-idle')
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]') const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)') expect(splitButtonWhileStopping.attributes('data-label')).toBe(
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary') 'Run (Instant)'
)
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(commandStore.execute).not.toHaveBeenCalled() expect(commandStore.execute).not.toHaveBeenCalled()
const queueButton = wrapper.get('[data-testid="queue-button"]') const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle') expect(queueSettingsStore.mode).toBe('instant-idle')
expect(queueButton.text()).toContain('Run (Instant)') expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary') expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
}) })

View File

@@ -1,83 +1,48 @@
<template> <template>
<ButtonGroup <div class="queue-button-group flex">
class="queue-button-group h-8 rounded-lg bg-secondary-background" <SplitButton
>
<BatchCountEdit />
<Button
v-tooltip.bottom="{ v-tooltip.bottom="{
value: queueButtonTooltip, value: queueButtonTooltip,
showDelay: 600 showDelay: 600
}" }"
:variant="queueButtonVariant" class="comfyui-queue-button"
size="unset" :label="queueButtonLabel"
:class="queueActionButtonClass" :severity="queueButtonSeverity"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button" data-testid="queue-button"
:data-variant="queueButtonVariant"
@click="queuePrompt" @click="queuePrompt"
> >
<i :class="cn(iconClass, 'size-4')" /> <template #icon>
{{ queueButtonLabel }} <i :class="iconClass" />
</Button> </template>
<template #item="{ item }">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button <Button
variant="secondary" v-tooltip="{
size="unset" value: item.tooltip,
:class="queueMenuTriggerClass" showDelay: 600
:aria-label="t('menu.run')" }"
data-testid="queue-mode-menu-trigger" :variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
size="sm"
class="w-full justify-start"
> >
<TinyChevronIcon /> <i v-if="item.icon" :class="item.icon" />
{{ String(item.label ?? '') }}
</Button> </Button>
</DropdownMenuTrigger> </template>
<DropdownMenuPortal> </SplitButton>
<DropdownMenuContent <BatchCountEdit />
:side-offset="4" </div>
class="z-1000 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem
v-for="item in queueModeMenuItems"
:key="item.key"
as-child
@select.prevent="item.command"
>
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:variant="
item.key === selectedQueueMode ? 'primary' : 'secondary'
"
size="sm"
:class="queueMenuItemButtonClass"
>
{{ item.label }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
@@ -89,9 +54,10 @@ import {
useQueueSettingsStore useQueueSettingsStore
} from '@/stores/queueStore' } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes' import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore()) const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
@@ -103,60 +69,50 @@ const hasMissingNodes = computed(() =>
const { t } = useI18n() const { t } = useI18n()
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle' type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
interface QueueModeMenuItem {
key: QueueModeMenuKey
label: string
tooltip: string
command: () => void
}
const selectedQueueMode = computed<QueueModeMenuKey>(() => const selectedQueueMode = computed<QueueModeMenuKey>(() =>
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
) )
const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>( const queueModeMenuItemLookup = computed(() => {
() => { const items: Record<string, MenuItem> = {
const items: Record<string, QueueModeMenuItem> = { disabled: {
disabled: { key: 'disabled',
key: 'disabled', label: t('menu.run'),
label: t('menu.run'), tooltip: t('menu.disabledTooltip'),
tooltip: t('menu.disabledTooltip'), command: () => {
command: () => { queueMode.value = 'disabled'
queueMode.value = 'disabled' }
} },
}, change: {
change: { key: 'change',
key: 'change', label: `${t('menu.run')} (${t('menu.onChange')})`,
label: `${t('menu.run')} (${t('menu.onChange')})`, tooltip: t('menu.onChangeTooltip'),
tooltip: t('menu.onChangeTooltip'), command: () => {
command: () => { useTelemetry()?.trackUiButtonClicked({
useTelemetry()?.trackUiButtonClicked({ button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected' })
}) queueMode.value = 'change'
queueMode.value = 'change'
}
} }
} }
if (!isCloud) {
items['instant-idle'] = {
key: 'instant-idle',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
}
}
return items
} }
) if (!isCloud) {
items['instant-idle'] = {
key: 'instant-idle',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
}
}
return items
})
const activeQueueModeMenuItem = computed(() => { const activeQueueModeMenuItem = computed(() => {
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
return ( return (
queueModeMenuItemLookup.value[selectedQueueMode.value] || queueModeMenuItemLookup.value[selectedQueueMode.value] ||
queueModeMenuItemLookup.value.disabled queueModeMenuItemLookup.value.disabled
@@ -176,13 +132,9 @@ const queueButtonLabel = computed(() =>
: String(activeQueueModeMenuItem.value?.label ?? '') : String(activeQueueModeMenuItem.value?.label ?? '')
) )
const queueButtonVariant = computed<'destructive' | 'primary'>(() => const queueButtonSeverity = computed(() =>
isStopInstantAction.value ? 'destructive' : 'primary' isStopInstantAction.value ? 'danger' : 'primary'
) )
const queueActionButtonClass = 'h-full rounded-lg gap-1.5 px-4 font-light'
const queueMenuTriggerClass =
'h-full w-6 rounded-l-none rounded-r-lg border-l border-border-subtle p-0 text-muted-foreground data-[state=open]:bg-secondary-background-hover'
const queueMenuItemButtonClass = 'w-full justify-start font-normal'
const iconClass = computed(() => { const iconClass = computed(() => {
if (isStopInstantAction.value) { if (isStopInstantAction.value) {
@@ -249,3 +201,10 @@ const queuePrompt = async (e: Event) => {
}) })
} }
</script> </script>
<style scoped>
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -1,26 +0,0 @@
<template>
<svg
class="h-[5px] min-h-[5px] w-[8px] min-w-[8px]"
:class="{ 'rotate-180': rotateUp }"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="5"
viewBox="0 0 8 5"
fill="none"
aria-hidden="true"
>
<path
d="M0.650391 0.649902L3.65039 3.6499L6.65039 0.649902"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<script setup lang="ts">
const { rotateUp = false } = defineProps<{
rotateUp?: boolean
}>()
</script>

View File

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

View File

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

View File

@@ -10,11 +10,9 @@ import PropertiesAccordionItem from '@/components/rightSidePanel/layout/Properti
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue' import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
LGraphEventMode,
TitleMode
} from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget' import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
@@ -24,9 +22,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget' import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil' import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode' import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore' import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil' import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes' import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -72,12 +70,15 @@ const inputsWithState = computed(() =>
} }
} }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return { return {
nodeId, nodeId,
widgetName, widgetName,
label: widget.label, label: widget.label,
subLabel: node.title, subLabel: node.title,
rename: () => promptRenameWidget(widget, node, t) rename
} }
}) })
) )
@@ -88,6 +89,20 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
]) ])
) )
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered( function getHovered(
e: MouseEvent e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] { ): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -144,12 +159,7 @@ function handleDown(e: MouseEvent) {
} }
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? [] const [node, widget] = getHovered(e) ?? []
if ( if (!node) return canvasInteractions.forwardEventToCanvas(e)
node?.mode !== LGraphEventMode.ALWAYS ||
!nodeTypeValidForApp(node.type) ||
node.has_errors
)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) { if (!widget) {
if (!isSelectOutputsMode.value) return if (!isSelectOutputsMode.value) return
@@ -182,12 +192,7 @@ function nodeToDisplayTuple(
const renderedOutputs = computed(() => { const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length void appModeStore.selectedOutputs.length
return canvas return canvas
.graph!.nodes.filter( .graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
(n) =>
n.constructor.nodeData?.output_node &&
n.mode === LGraphEventMode.ALWAYS &&
!n.has_errors
)
.map(nodeToDisplayTuple) .map(nodeToDisplayTuple)
}) })
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>( const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@@ -199,152 +204,131 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
) )
</script> </script>
<template> <template>
<div class="flex h-full flex-col"> <div class="flex items-center border-b border-border-subtle p-2 font-bold">
<div class="flex items-center border-b border-border-subtle p-2 font-bold"> {{
{{ isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title') }}
}}
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
>
{{ t('linearMode.builder.inputPlaceholder') }}
</div>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
>
{{ t('linearMode.builder.outputPlaceholder') }}
<span class="font-bold">
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
</span>
</div>
</div>
</div> </div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport <Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')" v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue' import Popover from '@/components/ui/Popover.vue'
@@ -7,13 +7,6 @@ import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n() const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{ const { rename, remove } = defineProps<{
title: string title: string
subTitle?: string subTitle?: string
@@ -39,28 +32,15 @@ const entries = computed(() => {
}) })
</script> </script>
<template> <template>
<div <div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
class="my-2 flex items-center-safe gap-2 rounded-lg p-2" <div
data-testid="builder-io-item" class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
> v-text="title"
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1"> />
<div <div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }" class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
class="drag-handle truncate text-sm" v-text="subTitle"
data-testid="builder-io-item-title" />
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
class="drag-handle truncate text-xs text-muted-foreground"
data-testid="builder-io-item-subtitle"
@mouseenter="
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
"
v-text="subTitle"
/>
</div>
<Popover :entries> <Popover :entries>
<template #button> <template #button>
<Button variant="muted-textonly"> <Button variant="muted-textonly">

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<Avatar <Avatar
class="aspect-square bg-interface-panel-selected-surface" class="bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined" :image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'" :icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }" :pt:icon:class="{ 'size-4': !hasAvatar }"

View File

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

View File

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

View File

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

View File

@@ -138,7 +138,8 @@ onMounted(async () => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs') detail: t('toastMessages.failedToFetchLogs'),
life: 5000
}) })
} }
}) })

View File

@@ -275,7 +275,8 @@ async function handleBuy() {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('credits.topUp.purchaseError'), summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }) detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
}) })
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -98,7 +98,8 @@ async function onConfirmCancel() {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('subscription.cancelDialog.failed'), summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError') detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
}) })
} finally { } finally {
isLoading.value = false isLoading.value = false

View File

@@ -579,7 +579,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed') detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
}) })
return return
} }
@@ -596,7 +597,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError') detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
}) })
} }
} }

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