Compare commits

...

14 Commits

Author SHA1 Message Date
CodeRabbit Fixer
5679af17b1 fix: add new color picker components to knip ignore list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:55:02 +01:00
CodeRabbit Fixer
f3af3c90a7 fix: keyboard accessibility for ColorPickerSaturationValue and ColorPickerSlider (#9651)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:51:44 +01:00
Hunter
63c36d3f2f feat: display original asset names instead of hashes in assets panel (#9626)
## Problem
Output assets in the assets panel show content hashes (e.g.,
`a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`).

## Root Cause
Cloud inference replaces `filename` with the content hash in the output
transform pipeline. The hashed filename gets stored in the jobs table's
`preview_output` JSONB. The frontend uses this hash as the display name.

## Solution
- Add `display_name` field to `AssetItem` schema and `ResultItemImpl`
- Backend (cloud PR) joins job→assets table to resolve the original name
and injects `display_name` into job responses
- Frontend prefers `display_name` over `name` **only for display text
and download filenames**
- `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas,
export filters, and output key dedup

## Backwards Compatible
- OSS: `display_name` is undefined, falls back to `asset.name` (which is
already the real filename in OSS)
- Cloud pre-deploy: `display_name` absent from API, falls back
gracefully
- Old jobs with no assets: `display_name` not injected, no change

## Cloud PR
https://github.com/Comfy-Org/cloud/pull/2747



https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602
2026-03-09 01:06:28 -04:00
pythongosssss
892a9cf2c5 fix: prevent showing outputs in app mode when no output nodes configured (#9625)
## Summary

After a user runs the workflow once in graph mode, switching to app mode
with no app built, incorrectly showed the app mode outputs view instead
of the intro screen

## Changes

- **What**: don't try and select outputs if no outputs & filter out all
outputs when nothing chosen
2026-03-08 17:36:15 -07:00
Comfy Org PR Bot
308c22efc6 1.42.2 (#9629)
Patch version increment to 1.42.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9629-1-42-2-31e6d73d365081faa106d97ae431e2e6)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-08 17:24:52 -07:00
Hunter
5728d240da fix: restore backend outputs_count for asset sidebar multi-output badge (#9627)
## Summary

Fix regression from PR #9535 where the multi-output count badge stopped
appearing in the asset sidebar.

## Root Cause

PR #9535 changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (backend-provided total) to
`task.previewableOutputs.length`. However, `TaskItemImpl` constructed
from a job listing only has the single `preview_output`, so
`previewableOutputs.length` is always **1** — the multi-output badge
never appears.

## Fix

Use the backend-provided `outputs_count` (via `task.outputsCount`) with
fallback to `task.previewableOutputs.length` when unavailable. This
restores the correct count while preserving the fallback for jobs that
don't have `outputs_count` from the server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9627-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-badge-31d6d73d36508160b93fd03af4a01aa3)
by [Unito](https://www.unito.io)
2026-03-08 13:17:22 -07:00
Kelly Yang
acf2f4280c fix(maskeditor): make brush size slider logarithmic (#8097) (#9534)
## Summary
fix #8097.

This PR shifts the Mask Editor Brush Size slider from a linear scale to
a logarithmic (exponential) scale. Previously, the linear 1-250 range
heavily clumped the usable, small "fine-detail" brush sizes (e.g., 1px
to 20px) into the very first 10% of the slider, making it extremely
difficult to select precise sizes with the mouse.

This update borrows UX paradigms from other standard image editors like
Photoshop and GIMP, which map their scale entry widgets on an
exponential curve.

## GIMP Source
By inspecting the official **GIMP** source code under
`libgimpwidgets/gimpscaleentry.c`, we can see this exact mathematical
relationship being utilized when the logarithmic property is marked TRUE
on a brush radius adjustment widget:

```
// Mapping visual slider to internal value
value = gtk_adjustment_get_lower(...) + exp(t);
// Mapping internal value to visual slider
t = log (value - gtk_adjustment_get_lower(...) + 0.1);
```


https://github.com/user-attachments/assets/6d59ff12-f623-42cc-a52b-84147e9bb90b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9534-fix-maskeditor-make-brush-size-slider-logarithmic-8097-31c6d73d365081118508e8363e0c5312)
by [Unito](https://www.unito.io)
2026-03-08 09:11:19 -07:00
Comfy Org PR Bot
7ad6994d01 1.42.1 (#9546)
Patch version increment to 1.42.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9546-1-42-1-31d6d73d365081a781fdebfef024a7cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 18:28:06 -08:00
Christian Byrne
2829f78579 fix: use previewable output count for asset sidebar badge (#9535)
## Summary

Fix asset sidebar badge showing inflated output count by using
previewable outputs length instead of raw server count.

## Changes

- **What**: Changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (includes all output types: text, JSON, custom data)
to `task.previewableOutputs.length` (only image, video, audio, 3D). The
badge now matches what users actually see in the expanded view.

## Review Focus

One-line change. The `task.previewableOutputs` array is already computed
on the line immediately below and used for `allOutputs`, so this
introduces no new computation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9535-fix-use-previewable-output-count-for-asset-sidebar-badge-31c6d73d365081c49161caec64cf3921)
by [Unito](https://www.unito.io)
2026-03-07 18:03:21 -08:00
pythongosssss
c4156d7059 feat/fix: App mode further updates (#9545)
## Summary

Additional updates

## Changes

- **What**: 
- Share widget rename functionality with properties panel implementation
- Add hammer icon to builder mode tabs
- Change (!) to (i) on app builder info sections

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9545-feat-fix-App-mode-further-updates-31c6d73d36508104aaa9c5f1e6205a0b)
by [Unito](https://www.unito.io)
2026-03-07 16:03:55 -08:00
Christian Byrne
725a0a2b89 fix: remove timeouts from error toasts so they persist until dismissed (#9543)
## Summary

Remove `life` (timeout) property from all error-severity toast calls so
they persist until manually dismissed, preventing users from missing
important error messages.

## Changes

- **What**: Removed `life` property from 86 error toast calls across 46
files. Error toasts now use PrimeVue's default behavior (no
auto-dismiss). Non-error toasts (success, warn, info) are unchanged.
- Also fixed a pre-existing lint issue in `TaskListPanel.vue` (`import {
t } from '@/i18n'` → `useI18n()`)

## Review Focus

- One conditional toast in `useMediaAssetActions.ts` intentionally keeps
`life` because its severity alternates between `warn` and `error`

Fixes
https://www.notion.so/comfy-org/Implement-Remove-timeouts-for-all-error-toasts-31b6d73d365081cead54fddc77ae7c3d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9543-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismissed-31c6d73d365081fa8d30f6366e9bfe38)
by [Unito](https://www.unito.io)
2026-03-07 15:08:13 -08:00
Alexander Brown
8a5bcde168 fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542)
## Summary

Fix non-widget inputs on nested subgraphs appearing twice — once as
slots and once as unresolved button widgets.

## Changes

- **What**: Add `getTargetWidget()` guard in the `isSubgraphNode()`
branch of `resolveSubgraphInputTarget`, matching the existing check for
non-subgraph nodes. Non-widget inputs (e.g. AUDIO, IMAGE) now return
`undefined` instead of a bogus promotion entry.

## Review Focus

`resolveSubgraphInputTarget` had an asymmetry: the non-subgraph branch
checked `getTargetWidget()` before returning, but the `isSubgraphNode()`
branch returned unconditionally for every input. For nested subgraphs
where non-widget slots are linked through to inner SubgraphNode inputs,
this created `PromotedWidgetView` entries that failed `resolveDeepest()`
(falling back to `type: 'button'`), while the inputs also rendered as
normal slot circles since `input.widget` was never set by
`_resolveInputWidget` (which correctly skipped them).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9542-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-button-widgets-31c6d73d3650816387c3f97f0385e762)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 22:58:59 +00:00
AustinMroz
83ffaf30c8 Yet further app fixes (#9523)
- Prevent selection of note nodes
- Prevents selection  of nodes with errors
- A bit broader than the reported "can select missing nodes". A node
with an error is a node that can not execute and thus can not be used in
an app.
- Updates the typeform survey
- Add a collapsible list of all api nodes(/prices) contained in an app.
  - Needs to be prettied up for mobile still.
<img width="322" height="751" alt="image"
src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd)
by [Unito](https://www.unito.io)
2026-03-07 14:22:15 -08:00
Christian Byrne
2875f897dc fix: remove workspace switching confirmation dialog (#9250)
## Summary

Remove the workspace switching confirmation dialog since switching
workspaces no longer discards unsaved changes.

## Changes

- **What**: Remove `hasUnsavedChanges` check, `dialogService.confirm`
call, and unused imports (`useI18n`, `useWorkflowStore`,
`useDialogService`) from `useWorkspaceSwitch`. Rename
`switchWithConfirmation` to `switchWorkspace`. Update callers
(`WorkspaceSwitcherPopover.vue`, `InviteAcceptedToast.vue`). Remove
`workspace.unsavedChanges` i18n entries from all 12 locale files.
Simplify tests to cover core switching behavior only.

## Review Focus

The confirmation dialog was showing inaccurate information (warning
about discarding unsaved changes when that no longer happens). This is a
pure removal with no new behavior.

<!-- Pipeline-Ticket: COM-15441 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9250-fix-remove-workspace-switching-confirmation-dialog-3136d73d365081d3b959da22e8f151d1)
by [Unito](https://www.unito.io)
2026-03-07 14:19:05 -08:00
110 changed files with 930 additions and 489 deletions

View File

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

View File

@@ -189,8 +189,7 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue'),
life: 5_000
detail: t('maintenance.error.cannotContinue')
})
}
}

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import PropertiesAccordionItem from '@/components/rightSidePanel/layout/Properti
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
@@ -25,9 +24,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -73,15 +72,12 @@ const inputsWithState = computed(() =>
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
rename: () => promptRenameWidget(widget, node, t)
}
})
)
@@ -92,20 +88,6 @@ 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(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -162,7 +144,11 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
if (
node?.mode !== LGraphEventMode.ALWAYS ||
!nodeTypeValidForApp(node.type) ||
node.has_errors
)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
@@ -198,7 +184,9 @@ const renderedOutputs = computed(() => {
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
n.constructor.nodeData?.output_node &&
n.mode === LGraphEventMode.ALWAYS &&
!n.has_errors
)
.map(nodeToDisplayTuple)
})
@@ -260,7 +248,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>
@@ -315,7 +303,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,12 +72,12 @@
/>
</div>
<SliderControl
v-model="brushSize"
v-model="brushSizeSliderValue"
class="flex-1"
label=""
:min="1"
:max="250"
:step="1"
:min="0"
:max="1"
:step="0.001"
/>
</div>
@@ -182,6 +182,26 @@ const brushSize = computed({
set: (value: number) => store.setBrushSize(value)
})
const rawSliderValue = ref<number | null>(null)
const brushSizeSliderValue = computed({
get: () => {
if (rawSliderValue.value !== null) {
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
if (cachedSize === brushSize.value) {
return rawSliderValue.value
}
}
return Math.log(brushSize.value) / Math.log(250)
},
set: (value: number) => {
rawSliderValue.value = value
const size = Math.round(Math.pow(250, value))
store.setBrushSize(size)
}
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)

View File

@@ -15,10 +15,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
})
async function handleRename() {
const newLabel = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
label.value = newLabel
const newLabel = await promptWidgetLabel(widget, t)
if (newLabel !== null) label.value = newLabel
}
function handleHideInput() {

View File

@@ -33,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,8 +133,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
return truncateFilename(getAssetDisplayName(asset))
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
title: asset.display_name || asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
detail: t('sideToolbar.folderView.errorDetail')
})
exitFolderView()
}
@@ -662,8 +661,7 @@ const copyJobId = async () => {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
})
}
}

View File

@@ -10,8 +10,9 @@
@mouseup="handleMouseUp"
@click="handleClick"
>
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
<i
v-if="workflowOption.workflow.initialMode === 'app'"
v-else-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
@@ -149,6 +150,11 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isBuilderState = computed(() => {
const currentMode = props.workflowOption.workflow.activeMode
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})

View File

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

View File

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

View File

@@ -397,8 +397,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (app.canvas.empty) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.emptyCanvas'),
life: 3000
summary: t('toastMessages.emptyCanvas')
})
return
}
@@ -557,8 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
detail: t('toastMessages.pleaseSelectOutputNodes'),
life: 3000
detail: t('toastMessages.pleaseSelectOutputNodes')
})
return
}
@@ -571,8 +569,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.failedToQueue'),
detail: t('toastMessages.failedExecutionPathResolution'),
life: 3000
detail: t('toastMessages.failedExecutionPathResolution')
})
return
}
@@ -602,8 +599,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup'),
life: 3000
detail: t('toastMessages.pleaseSelectNodesToGroup')
})
return
}
@@ -962,8 +958,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.notAvailable'),
life: 3000
detail: t('manager.notAvailable')
})
return
}
@@ -1048,8 +1043,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
detail: t('toastMessages.failedToConvertToSubgraph')
})
return
}
@@ -1258,8 +1252,7 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
}),
life: 3000
})
})
return
}
@@ -1278,8 +1271,7 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
}),
life: 3000
})
})
return
}

View File

@@ -81,8 +81,7 @@ function getParentNodes(): SubgraphNode[] {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
detail: t('subgraphStore.promoteOutsideSubgraph')
})
return []
}

View File

@@ -0,0 +1,161 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createOuterSubgraphSetup(inputNames: string[]): {
outerSubgraph: Subgraph
outerSubgraphNode: SubgraphNode
} {
const outerSubgraph = createTestSubgraph({
inputs: inputNames.map((name) => ({ name, type: '*' }))
})
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
return { outerSubgraph, outerSubgraphNode }
}
function addLinkedNestedSubgraphNode(
outerSubgraph: Subgraph,
inputName: string,
linkedInputName: string,
options: { widget?: string } = {}
): { innerSubgraphNode: SubgraphNode } {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: linkedInputName, type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
outerSubgraph.add(innerSubgraphNode)
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
const input = innerSubgraphNode.addInput(linkedInputName, '*')
if (options.widget) {
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
input.widget = { name: options.widget }
}
inputSlot.connect(input, innerSubgraphNode)
if (input.link == null) {
throw new Error(`Expected link to be created for input ${linkedInputName}`)
}
return { innerSubgraphNode }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('resolveSubgraphInputTarget', () => {
test('returns target for widget-backed input on nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
expect(result).toMatchObject({
nodeId: '819',
widgetName: 'width'
})
})
test('returns undefined for non-widget input on nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
expect(result).toBeUndefined()
})
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '819',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
})
test('returns target for widget-backed input on plain interior node', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'seed'
])
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'seed'
)!
const node = new LGraphNode('Interior-seed')
node.id = 42
const input = node.addInput('seed_input', '*')
node.addWidget('number', 'seed', 0, () => undefined)
input.widget = { name: 'seed' }
outerSubgraph.add(node)
inputSlot.connect(input, node)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
expect(result).toMatchObject({
nodeId: '42',
widgetName: 'seed'
})
})
test('returns undefined for non-widget input on plain interior node', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'image'
])
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'image'
)!
const node = new LGraphNode('Interior-image')
const input = node.addInput('image_input', '*')
outerSubgraph.add(node)
inputSlot.connect(input, node)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
expect(result).toBeUndefined()
})
})

View File

@@ -16,6 +16,9 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -204,8 +204,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorInstallingUpdate'),
life: 10_000
detail: t('desktopUpdate.errorInstallingUpdate')
})
}
}
@@ -214,8 +213,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorCheckingUpdate'),
life: 10_000
detail: t('desktopUpdate.errorCheckingUpdate')
})
}
}

View File

@@ -952,6 +952,7 @@
"collapseAll": "طي الكل",
"color": "اللون",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "شعار ComfyOrg",
"comingSoon": "قريباً",
"command": "أمر",
@@ -1459,12 +1460,14 @@
"unknownWidget": "عنصر الواجهة غير مرئي"
},
"cancelThisRun": "إلغاء هذا التشغيل",
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
"enterNodeGraph": "دخول مخطط العقد",
"giveFeedback": "إعطاء ملاحظات",
"graphMode": "وضع الرسم البياني",
"hasCreditCost": "يتطلب أرصدة إضافية",
"linearMode": "وضع التطبيق",
"loadTemplate": "تحميل قالب",
"mobileControls": "تعديل وتشغيل",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
"inviteAccepted": "تم قبول الدعوة",
"inviteFailed": "فشل في قبول الدعوة",
"unsavedChanges": {
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
"title": "تغييرات غير محفوظة"
},
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
"viewWorkspace": "عرض مساحة العمل"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "تخطيط شريط التبويبات",
"options": {
"Default": "افتراضي",
"Integrated": "مُدمج"
"Legacy": "تقليدي"
},
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
},

View File

@@ -483,6 +483,13 @@
"black": "Black",
"custom": "Custom"
},
"colorPicker": {
"saturationValue": "Saturation and brightness",
"saturation": "Saturation",
"brightness": "Brightness",
"hue": "Hue",
"alpha": "Alpha"
},
"contextMenu": {
"Inputs": "Inputs",
"Outputs": "Outputs",
@@ -3179,6 +3186,7 @@
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3405,14 +3413,11 @@
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
"viewWorkspace": "View workspace",
"switchFailed": "Failed to switch workspace. Please try again."
},
"workspaceAuth": {
"errors": {

View File

@@ -398,10 +398,10 @@
},
"Comfy_UI_TabBarLayout": {
"name": "Tab Bar Layout",
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
"tooltip": "Controls the elements contained in the integrated tab bar.",
"options": {
"Default": "Default",
"Integrated": "Integrated"
"Legacy": "Legacy"
}
},
"Comfy_UseNewMenu": {

View File

@@ -952,6 +952,7 @@
"collapseAll": "Colapsar todo",
"color": "Color",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
"comingSoon": "Próximamente",
"command": "Comando",
@@ -1459,12 +1460,14 @@
"unknownWidget": "Widget no visible"
},
"cancelThisRun": "Cancelar esta ejecución",
"deleteAllAssets": "Eliminar todos los recursos de esta ejecución",
"downloadAll": "Descargar todo",
"dragAndDropImage": "Arrastra y suelta una imagen",
"emptyWorkflowExplanation": "Tu flujo de trabajo está vacío. Necesitas algunos nodos primero para empezar a construir una aplicación.",
"enterNodeGraph": "Entrar al grafo de nodos",
"giveFeedback": "Enviar comentarios",
"graphMode": "Modo gráfico",
"hasCreditCost": "Requiere créditos adicionales",
"linearMode": "Modo App",
"loadTemplate": "Cargar una plantilla",
"mobileControls": "Editar y ejecutar",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "Has sido añadido a {workspaceName}",
"inviteAccepted": "Invitación aceptada",
"inviteFailed": "No se pudo aceptar la invitación",
"unsavedChanges": {
"message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?",
"title": "Cambios no guardados"
},
"switchFailed": "No se pudo cambiar de espacio de trabajo. Por favor, inténtalo de nuevo.",
"viewWorkspace": "Ver espacio de trabajo"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Diseño de barra de pestañas",
"options": {
"Default": "Predeterminado",
"Integrated": "Integrado"
"Legacy": "Clásico"
},
"tooltip": "Controla el diseño de la barra de pestañas. \"Integrado\" mueve los controles de Ayuda y Usuario al área de la barra de pestañas."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "بستن همه",
"color": "رنگ",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
"comingSoon": "به‌زودی",
"command": "دستور",
@@ -1459,12 +1460,14 @@
"unknownWidget": "ویجت قابل مشاهده نیست"
},
"cancelThisRun": "لغو این اجرا",
"deleteAllAssets": "حذف همه دارایی‌ها از این اجرا",
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"emptyWorkflowExplanation": "جریان کاری شما خالی است. ابتدا باید چند node اضافه کنید تا بتوانید یک برنامه بسازید.",
"enterNodeGraph": "ورود به گراف node",
"giveFeedback": "ارسال بازخورد",
"graphMode": "حالت گراف",
"hasCreditCost": "نیازمند اعتبار اضافی",
"linearMode": "حالت برنامه",
"loadTemplate": "بارگذاری قالب",
"mobileControls": "ویرایش و اجرا",
@@ -3385,10 +3388,7 @@
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
"inviteAccepted": "دعوت پذیرفته شد",
"inviteFailed": "پذیرش دعوت ناموفق بود",
"unsavedChanges": {
"message": "شما تغییرات ذخیره‌نشده دارید. آیا می‌خواهید آن‌ها را رها کرده و فضای کاری را تغییر دهید؟",
"title": "تغییرات ذخیره‌نشده"
},
"switchFailed": "تغییر ورک‌اسپیس ناموفق بود. لطفاً دوباره تلاش کنید.",
"viewWorkspace": "مشاهده workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "چیدمان نوار تب",
"options": {
"Default": "پیش‌فرض",
"Integrated": "یکپارچه"
"Legacy": "قدیمی"
},
"tooltip": "چیدمان نوار تب را کنترل می‌کند. «یکپارچه» کنترل‌های راهنما و کاربر را به ناحیه نوار تب منتقل می‌کند."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "Tout réduire",
"color": "Couleur",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo ComfyOrg",
"comingSoon": "Bientôt disponible",
"command": "Commande",
@@ -1459,12 +1460,14 @@
"unknownWidget": "Widget non visible"
},
"cancelThisRun": "Annuler cette exécution",
"deleteAllAssets": "Supprimer toutes les ressources de cette exécution",
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"emptyWorkflowExplanation": "Votre workflow est vide. Vous devez d'abord ajouter des nodes pour commencer à créer une application.",
"enterNodeGraph": "Entrer dans le graphique de nœuds",
"giveFeedback": "Donner un avis",
"graphMode": "Mode graphique",
"hasCreditCost": "Nécessite des crédits supplémentaires",
"linearMode": "Mode App",
"loadTemplate": "Charger un modèle",
"mobileControls": "Éditer & Exécuter",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
"inviteAccepted": "Invitation acceptée",
"inviteFailed": "Échec de l'acceptation de l'invitation",
"unsavedChanges": {
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer despace de travail ?",
"title": "Modifications non enregistrées"
},
"switchFailed": "Échec du changement despace de travail. Veuillez réessayer.",
"viewWorkspace": "Voir lespace de travail"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Disposition de la barre donglets",
"options": {
"Default": "Par défaut",
"Integrated": "Intégrée"
"Legacy": "Héritage"
},
"tooltip": "Contrôle la disposition de la barre donglets. « Intégrée » déplace les contrôles Aide et Utilisateur dans la zone de la barre donglets."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "すべて折りたたむ",
"color": "色",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
"comingSoon": "近日公開",
"command": "コマンド",
@@ -1459,12 +1460,14 @@
"unknownWidget": "ウィジェットが表示されていません"
},
"cancelThisRun": "この実行をキャンセル",
"deleteAllAssets": "この実行からすべてのアセットを削除",
"downloadAll": "すべてダウンロード",
"dragAndDropImage": "画像をドラッグ&ドロップ",
"emptyWorkflowExplanation": "ワークフローが空です。アプリを作成するには、まずノードを追加してください。",
"enterNodeGraph": "ノードグラフに入る",
"giveFeedback": "フィードバックを送る",
"graphMode": "グラフモード",
"hasCreditCost": "追加クレジットが必要です",
"linearMode": "アプリモード",
"loadTemplate": "テンプレートを読み込む",
"mobileControls": "編集と実行",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "{workspaceName}に追加されました",
"inviteAccepted": "招待を承諾しました",
"inviteFailed": "招待の承諾に失敗しました",
"unsavedChanges": {
"message": "未保存の変更があります。破棄してワークスペースを切り替えますか?",
"title": "未保存の変更"
},
"switchFailed": "ワークスペースの切り替えに失敗しました。もう一度お試しください。",
"viewWorkspace": "ワークスペースを見る"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "タブバーのレイアウト",
"options": {
"Default": "デフォルト",
"Integrated": "統合"
"Legacy": "レガシー"
},
"tooltip": "タブバーのレイアウトを制御します。「統合」を選択すると、ヘルプとユーザーコントロールがタブバーエリアに移動します。"
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "모두 접기",
"color": "색상",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 로고",
"comingSoon": "곧 출시 예정",
"command": "명령",
@@ -1459,12 +1460,14 @@
"unknownWidget": "위젯이 표시되지 않습니다"
},
"cancelThisRun": "이 실행 취소",
"deleteAllAssets": "이 실행에서 모든 에셋 삭제",
"downloadAll": "모두 다운로드",
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
"emptyWorkflowExplanation": "워크플로우가 비어 있습니다. 앱을 만들려면 먼저 노드를 추가해야 합니다.",
"enterNodeGraph": "노드 그래프로 진입",
"giveFeedback": "피드백 보내기",
"graphMode": "그래프 모드",
"hasCreditCost": "추가 크레딧 필요",
"linearMode": "앱 모드",
"loadTemplate": "템플릿 불러오기",
"mobileControls": "편집 및 실행",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
"inviteAccepted": "초대 수락됨",
"inviteFailed": "초대 수락에 실패했습니다",
"unsavedChanges": {
"message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?",
"title": "저장되지 않은 변경 사항"
},
"switchFailed": "워크스페이스 전환에 실패했습니다. 다시 시도해 주세요.",
"viewWorkspace": "워크스페이스 보기"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "탭 바 레이아웃",
"options": {
"Default": "기본값",
"Integrated": "통합"
"Legacy": "레거시"
},
"tooltip": "탭 바의 레이아웃을 제어합니다. \"통합\"을 선택하면 도움말과 사용자 컨트롤이 탭 바 영역으로 이동합니다."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "Recolher tudo",
"color": "Cor",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
"comingSoon": "Em breve",
"command": "Comando",
@@ -1459,12 +1460,14 @@
"unknownWidget": "Widget não visível"
},
"cancelThisRun": "Cancelar esta execução",
"deleteAllAssets": "Excluir todos os ativos desta execução",
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"emptyWorkflowExplanation": "Seu fluxo de trabalho está vazio. Você precisa adicionar alguns nós primeiro para começar a construir um app.",
"enterNodeGraph": "Entrar no grafo de nós",
"giveFeedback": "Enviar feedback",
"graphMode": "Modo Gráfico",
"hasCreditCost": "Requer créditos adicionais",
"linearMode": "Modo App",
"loadTemplate": "Carregar um modelo",
"mobileControls": "Editar e Executar",
@@ -3385,10 +3388,7 @@
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
"inviteAccepted": "Convite aceito",
"inviteFailed": "Falha ao aceitar convite",
"unsavedChanges": {
"message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?",
"title": "Alterações não salvas"
},
"switchFailed": "Falha ao alternar o workspace. Por favor, tente novamente.",
"viewWorkspace": "Ver workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Layout da Barra de Abas",
"options": {
"Default": "Padrão",
"Integrated": "Integrado"
"Legacy": "Legado"
},
"tooltip": "Controla o layout da barra de abas. \"Integrado\" move os controles de Ajuda e Usuário para a área da barra de abas."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "Свернуть все",
"color": "Цвет",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Логотип ComfyOrg",
"comingSoon": "Скоро будет",
"command": "Команда",
@@ -1459,12 +1460,14 @@
"unknownWidget": "Виджет не отображается"
},
"cancelThisRun": "Отменить этот запуск",
"deleteAllAssets": "Удалить все ресурсы из этого запуска",
"downloadAll": "Скачать всё",
"dragAndDropImage": "Перетащите изображение",
"emptyWorkflowExplanation": "Ваш рабочий процесс пуст. Сначала добавьте несколько узлов, чтобы начать создавать приложение.",
"enterNodeGraph": "Войти в граф узлов",
"giveFeedback": "Оставить отзыв",
"graphMode": "Графовый режим",
"hasCreditCost": "Требуются дополнительные кредиты",
"linearMode": "Режим приложения",
"loadTemplate": "Загрузить шаблон",
"mobileControls": "Редактировать и запустить",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
"inviteAccepted": "Приглашение принято",
"inviteFailed": "Не удалось принять приглашение",
"unsavedChanges": {
"message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?",
"title": "Несохранённые изменения"
},
"switchFailed": "Не удалось переключить рабочее пространство. Пожалуйста, попробуйте еще раз.",
"viewWorkspace": "Просмотреть рабочее пространство"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Макет панели вкладок",
"options": {
"Default": "По умолчанию",
"Integrated": "Интегрированный"
"Legacy": "Классический"
},
"tooltip": "Управляет расположением панели вкладок. «Интегрированный» перемещает элементы управления Справкой и Пользователем в область панели вкладок."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "Hepsini daralt",
"color": "Renk",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
"comingSoon": "Çok Yakında",
"command": "Komut",
@@ -1459,12 +1460,14 @@
"unknownWidget": "Widget görünür değil"
},
"cancelThisRun": "Bu çalıştırmayı iptal et",
"deleteAllAssets": "Bu çalışmadaki tüm varlıkları sil",
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"emptyWorkflowExplanation": "Çalışma akışınız boş. Bir uygulama oluşturmaya başlamak için önce bazı düğümler eklemelisiniz.",
"enterNodeGraph": "Düğüm grafiğine gir",
"giveFeedback": "Geri bildirim ver",
"graphMode": "Grafik Modu",
"hasCreditCost": "Ekstra kredi gerektirir",
"linearMode": "Uygulama Modu",
"loadTemplate": "Şablon yükle",
"mobileControls": "Düzenle ve Çalıştır",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
"inviteAccepted": "Davet kabul edildi",
"inviteFailed": "Davet kabul edilemedi",
"unsavedChanges": {
"message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?",
"title": "Kaydedilmemiş Değişiklikler"
},
"switchFailed": "Çalışma alanı değiştirilemedi. Lütfen tekrar deneyin.",
"viewWorkspace": "Çalışma alanını görüntüle"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Sekme Çubuğu Düzeni",
"options": {
"Default": "Varsayılan",
"Integrated": "Entegre"
"Legacy": "Klasik"
},
"tooltip": "Sekme çubuğu düzenini kontrol eder. \"Entegre\" seçeneği, Yardım ve Kullanıcı kontrollerini sekme çubuğu alanına taşır."
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "全部摺疊",
"color": "顏色",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 標誌",
"comingSoon": "即將推出",
"command": "指令",
@@ -1459,12 +1460,14 @@
"unknownWidget": "元件不可見"
},
"cancelThisRun": "取消本次執行",
"deleteAllAssets": "刪除本次運行的所有資產",
"downloadAll": "全部下載",
"dragAndDropImage": "拖曳圖片到此",
"emptyWorkflowExplanation": "您的工作流程目前是空的。您需要先新增一些節點,才能開始建立應用程式。",
"enterNodeGraph": "進入節點圖",
"giveFeedback": "提供回饋",
"graphMode": "圖形模式",
"hasCreditCost": "需要額外點數",
"linearMode": "App 模式",
"loadTemplate": "載入範本",
"mobileControls": "編輯與執行",
@@ -3373,10 +3376,7 @@
"addedToWorkspace": "你已被加入 {workspaceName}",
"inviteAccepted": "已接受邀請",
"inviteFailed": "接受邀請失敗",
"unsavedChanges": {
"message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?",
"title": "未儲存的變更"
},
"switchFailed": "切換工作區失敗。請再試一次。",
"viewWorkspace": "檢視工作區"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "分頁列佈局",
"options": {
"Default": "預設",
"Integrated": "整合"
"Legacy": "傳統"
},
"tooltip": "控制分頁列的佈局。「整合」會將說明和使用者控制項移至分頁列區域。"
},

View File

@@ -952,6 +952,7 @@
"collapseAll": "全部折叠",
"color": "颜色",
"comfy": "舒适",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
"comingSoon": "即将推出",
"command": "指令",
@@ -1459,12 +1460,14 @@
"unknownWidget": "组件不可见"
},
"cancelThisRun": "取消本次运行",
"deleteAllAssets": "删除本次运行的所有资源",
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"emptyWorkflowExplanation": "你的工作流为空。你需要先添加一些节点,才能开始构建应用。",
"enterNodeGraph": "进入节点图",
"giveFeedback": "提供反馈",
"graphMode": "图形模式",
"hasCreditCost": "需要额外积分",
"linearMode": "App 模式",
"loadTemplate": "加载模板",
"mobileControls": "编辑与运行",
@@ -3385,10 +3388,7 @@
"addedToWorkspace": "您已被加入 {workspaceName}",
"inviteAccepted": "邀请已接受",
"inviteFailed": "接受邀请失败",
"unsavedChanges": {
"message": "您有未保存的更改。是否要放弃这些更改并切换工作区?",
"title": "未保存的更改"
},
"switchFailed": "切换工作区失败。请重试。",
"viewWorkspace": "查看工作区"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "标签栏布局",
"options": {
"Default": "默认",
"Integrated": "集成"
"Legacy": "传统"
},
"tooltip": "控制标签栏的布局。“集成”会将帮助和用户控件移动到标签栏区域。"
},

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
function handleSelect() {

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
name: asset.display_name || asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,6 +234,7 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.preview_url || '',
size: asset.size,

View File

@@ -6,7 +6,7 @@
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:alt="asset.display_name || asset.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
/>
<div
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
whenever(

View File

@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
return {
id: taskItem.jobId,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()

View File

@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
if (!targetAsset) return
try {
const filename = targetAsset.name
const filename = targetAsset.display_name || targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -84,8 +84,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
detail: t('g.failedToDownloadImage')
})
}
}
@@ -110,7 +109,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
const filename = asset.display_name || asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -126,8 +125,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
detail: t('g.failedToDownloadImage')
})
}
}
@@ -182,8 +180,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle'),
life: 3000
detail: t('exportToast.exportFailedSingle')
})
}
}
@@ -238,8 +235,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.nodeTypeNotFound', { nodeType }),
life: 3000
detail: t('mediaAsset.nodeTypeNotFound', { nodeType })
})
return
}
@@ -252,8 +248,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.failedToCreateNode'),
life: 3000
detail: t('mediaAsset.failedToCreateNode')
})
return
}
@@ -443,8 +438,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.selection.failedToAddNodes'),
life: 3000
detail: t('mediaAsset.selection.failedToAddNodes')
})
} else {
toast.add({
@@ -676,8 +670,7 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
: t('mediaAsset.selection.failedToDeleteAssets')
})
} else {
// Partial success (only possible with multiple assets)
@@ -698,8 +691,7 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
: t('mediaAsset.selection.failedToDeleteAssets')
})
} finally {
// Hide loading overlay for all assets

View File

@@ -9,6 +9,7 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),

View File

@@ -73,8 +73,7 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidAsset'),
detail: t('assetBrowser.invalidAssetDetail'),
life: 5000
detail: t('assetBrowser.invalidAssetDetail')
})
return
}
@@ -92,8 +91,7 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidFilename'),
detail: t('assetBrowser.invalidFilenameDetail'),
life: 5000
detail: t('assetBrowser.invalidFilenameDetail')
})
return
}

View File

@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url
previewUrl: merged.url,
display_name: merged.display_name
} as ResultItemImpl
}
@@ -125,6 +127,48 @@ describe('resolveOutputAssetItems', () => {
])
})
it('propagates display_name from output to asset item', async () => {
const output = createOutput({
filename: 'abc123hash.png',
nodeId: '1',
url: 'https://example.com/abc123hash.png',
display_name: 'ComfyUI_00001_.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('abc123hash.png')
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
})
it('omits display_name when not present in output', async () => {
const output = createOutput({
filename: 'file.png',
nodeId: '1',
url: 'https://example.com/file.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-nodn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].display_name).toBeUndefined()
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -69,6 +69,7 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],

View File

@@ -317,8 +317,7 @@ export function useNodeReplacement() {
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes')
})
return replacedTypes
} finally {

View File

@@ -23,7 +23,8 @@ const zPreviewOutput = z.object({
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string()
mediaType: z.string(),
display_name: z.string().optional()
})
/**

View File

@@ -83,8 +83,7 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Network error',
life: 5000
detail: 'Network error'
})
})
})
@@ -130,8 +129,7 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Delete failed',
life: 5000
detail: 'Delete failed'
})
})
})

View File

@@ -33,16 +33,14 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message,
life: 5000
detail: err.message
})
} else {
console.error('Unexpected error fetching secrets:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError'),
life: 5000
detail: t('g.unknownError')
})
}
} finally {
@@ -60,16 +58,14 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message,
life: 5000
detail: err.message
})
} else {
console.error('Unexpected error deleting secret:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError'),
life: 5000
detail: t('g.unknownError')
})
}
} finally {

View File

@@ -370,8 +370,7 @@ export const useWorkflowService = () => {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
detail: t('toastMessages.failedToSaveDraft')
})
}
}

View File

@@ -112,8 +112,7 @@ export function useWorkflowPersistenceV2() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
detail: t('toastMessages.failedToSaveDraft')
})
return
}

View File

@@ -484,8 +484,7 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Publish failed',
life: 5000
detail: 'Publish failed'
})
})

View File

@@ -352,8 +352,7 @@ const { isLoading: isSaving, execute: handleSave } = useAsyncState(
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription'),
life: 5000
detail: t('shareWorkflow.saveFailedDescription')
})
}
}
@@ -391,8 +390,7 @@ const {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
detail: error instanceof Error ? error.message : t('g.error')
})
}
}

View File

@@ -183,8 +183,7 @@ async function handleCreate() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
detail: error instanceof Error ? error.message : t('g.error')
})
} finally {
isCreating.value = false

View File

@@ -338,8 +338,7 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load shared workflow',
life: 3000
detail: 'Failed to load shared workflow'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(

View File

@@ -118,8 +118,7 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed'),
life: 3000
detail: t('shareWorkflow.loadFailed')
})
cleanupUrlParams()
clearPreservedQuery(SHARE_NAMESPACE)
@@ -148,8 +147,7 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed'),
life: 5000
detail: t('shareWorkflow.loadFailed')
})
return 'failed'
}

View File

@@ -145,8 +145,7 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Template "invalid-template" not found',
life: 3000
detail: 'Template "invalid-template" not found'
})
})
@@ -239,8 +238,7 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load template',
life: 3000
detail: 'Failed to load template'
})
})

View File

@@ -117,8 +117,7 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('templateWorkflows.error.templateNotFound', {
templateName: templateParam
}),
life: 3000
})
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
@@ -132,8 +131,7 @@ export function useTemplateUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.errorLoadingTemplate'),
life: 3000
detail: t('g.errorLoadingTemplate')
})
} finally {
cleanupUrlParams()

View File

@@ -428,8 +428,7 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message,
life: 5000
detail: message
})
} finally {
isResubscribing.value = false

View File

@@ -148,8 +148,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: 'This plan is not available',
life: 5000
detail: 'This plan is not available'
})
return
}
@@ -159,8 +158,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: response?.reason || 'This plan is not available',
life: 5000
detail: response?.reason || 'This plan is not available'
})
return
}
@@ -175,8 +173,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isLoadingPreview.value = false
@@ -236,8 +233,7 @@ async function handleAddCreditCard() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isSubscribing.value = false
@@ -291,8 +287,7 @@ async function handleConfirmTransition() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isSubscribing.value = false
@@ -316,8 +311,7 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isResubscribing.value = false

View File

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

View File

@@ -139,7 +139,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = {
@@ -226,7 +226,7 @@ function getTierLabel(workspace: AvailableWorkspace): string | null {
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
const success = await switchWorkspace(workspace.id)
if (success) {
emit('select', workspace)
}

View File

@@ -102,8 +102,7 @@ async function onCreate() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

View File

@@ -79,8 +79,7 @@ async function onDelete() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

View File

@@ -94,8 +94,7 @@ async function onSave() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

View File

@@ -138,8 +138,7 @@ async function onCreateLink() {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
detail: error instanceof Error ? error.message : undefined
})
} finally {
loading.value = false
@@ -161,8 +160,7 @@ async function onCopyLink() {
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed')
})
}
}

View File

@@ -68,8 +68,7 @@ async function onLeave() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

View File

@@ -73,8 +73,7 @@ async function onRemove() {
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
summary: t('workspacePanel.removeMemberDialog.error')
})
} finally {
loading.value = false

View File

@@ -69,8 +69,7 @@ async function onRevoke() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
detail: error instanceof Error ? error.message : undefined
})
} finally {
loading.value = false

View File

@@ -543,8 +543,7 @@ async function handleCopyInviteLink(invite: PendingInvite) {
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
summary: t('g.error')
})
}
}

View File

@@ -33,10 +33,18 @@ import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspac
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
async function viewWorkspace(workspaceId: string) {
const success = await switchWorkspace(workspaceId)
if (success) {
toast.removeGroup('invite-accepted')
} else {
toast.add({
severity: 'error',
summary: t('workspace.switchFailed'),
life: 5000
})
}
}
</script>

View File

@@ -151,8 +151,7 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
detail: 'Invalid invite'
})
})
@@ -211,8 +210,7 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
detail: 'Invalid token'
})
})

View File

@@ -97,8 +97,7 @@ export function useInviteUrlLoader() {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
cleanupUrlParams()

View File

@@ -20,32 +20,6 @@ vi.mock('pinia', () => ({
})
}))
const mockModifiedWorkflows = vi.hoisted(
() => [] as Array<{ isModified: boolean }>
)
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get modifiedWorkflows() {
return mockModifiedWorkflows
}
})
}))
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -57,103 +31,37 @@ describe('useWorkspaceSwitch', () => {
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
mockModifiedWorkflows.length = 0
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('hasUnsavedChanges', () => {
it('returns true when there are modified workflows', () => {
mockModifiedWorkflows.push({ isModified: true })
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(true)
})
it('returns true when multiple workflows are modified', () => {
mockModifiedWorkflows.push({ isModified: true }, { isModified: true })
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(true)
})
it('returns false when no workflows are modified', () => {
mockModifiedWorkflows.length = 0
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(false)
})
})
describe('switchWithConfirmation', () => {
describe('switchWorkspace', () => {
it('returns true immediately if switching to the same workspace', async () => {
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-1')
const result = await switchWorkspace('workspace-1')
expect(result).toBe(true)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
})
it('switches directly without dialog when no unsaved changes', async () => {
mockModifiedWorkflows.length = 0
it('switches directly to the new workspace', async () => {
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(true)
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
})
it('shows confirmation dialog when there are unsaved changes', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
await switchWithConfirmation('workspace-2')
expect(mockConfirm).toHaveBeenCalledWith({
title: 'workspace.unsavedChanges.title',
message: 'workspace.unsavedChanges.message',
type: 'dirtyClose'
})
})
it('returns false if user cancels the confirmation dialog', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(false)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
})
it('calls switchWorkspace after user confirms', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
const result = await switchWorkspace('workspace-2')
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
})
it('returns false if switchWorkspace throws an error', async () => {
mockModifiedWorkflows.length = 0
mockSwitchWorkspace.mockRejectedValue(new Error('Switch failed'))
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
const result = await switchWorkspace('workspace-2')
expect(result).toBe(false)
})

View File

@@ -1,41 +1,18 @@
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const { activeWorkspace } = storeToRefs(workspaceStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
function hasUnsavedChanges(): boolean {
return workflowStore.modifiedWorkflows.length > 0
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
async function switchWorkspace(workspaceId: string): Promise<boolean> {
if (activeWorkspace.value?.id === workspaceId) {
return true
}
if (hasUnsavedChanges()) {
const confirmed = await dialogService.confirm({
title: t('workspace.unsavedChanges.title'),
message: t('workspace.unsavedChanges.message'),
type: 'dirtyClose'
})
if (!confirmed) {
return false
}
}
try {
await workspaceStore.switchWorkspace(workspaceId)
// Note: switchWorkspace triggers page reload internally
return true
} catch {
return false
@@ -43,7 +20,6 @@ export function useWorkspaceSwitch() {
}
return {
hasUnsavedChanges,
switchWithConfirmation
switchWorkspace
}
}

View File

@@ -219,8 +219,7 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionFailed',
detail: errorMessage,
life: 5000
detail: errorMessage
})
})
@@ -239,8 +238,7 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupFailed',
detail: undefined,
life: 5000
detail: undefined
})
})
})
@@ -267,8 +265,7 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionTimeout',
life: 5000
summary: 'billingOperation.subscriptionTimeout'
})
})
@@ -287,8 +284,7 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout',
life: 5000
summary: 'billingOperation.topupTimeout'
})
})
})

View File

@@ -173,8 +173,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined,
life: 5000
detail: errorMessage ?? undefined
})
}
@@ -192,8 +191,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
useToastStore().add({
severity: 'error',
summary: message,
life: 5000
summary: message
})
}

View File

@@ -25,7 +25,7 @@ function togglePromotion() {
<div
:class="
cn(
'pointer-events-auto absolute size-full rounded-2xl ring-5 ring-warning-background/50',
'pointer-events-auto absolute z-1 size-full rounded-2xl ring-5 ring-warning-background/50',
isPromoted && 'ring-warning-background'
)
"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { partition, remove, takeWhile } from 'es-toolkit'
import { remove, takeWhile } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -19,6 +19,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -119,20 +120,6 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
const partitionedNodes = computed(() => {
const parts = partition(
graphNodes.value
.filter((node) => node.mode === 0 && node.widgets?.length)
.map(nodeToNodeData)
.reverse(),
(node) => ['MarkdownNote', 'Note'].includes(node.type)
)
for (const noteNode of parts[0]) {
for (const widget of noteNode.widgets ?? [])
widget.options = { ...widget.options, read_only: true }
}
return parts
})
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -180,34 +167,6 @@ defineExpose({ runButtonClick })
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Popover
v-if="partitionedNodes[0].length"
align="end"
class="z-100 max-h-(--reka-popover-content-available-height) overflow-x-clip overflow-y-auto"
side="bottom"
:side-offset="-8"
>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--info]" />
</Button>
</template>
<div>
<template
v-for="(nodeData, index) in partitionedNodes[0]"
:key="nodeData.id"
>
<div
v-if="index !== 0"
class="w-full border-t border-border-subtle"
/>
<NodeWidgets
:node-data
class="max-w-100 gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1"
/>
</template>
</div>
</Popover>
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</section>
<div
@@ -218,9 +177,7 @@ defineExpose({ runButtonClick })
class="grow overflow-y-auto contain-size"
>
<template
v-for="(nodeData, index) of appModeStore.selectedInputs.length
? mappedSelections
: partitionedNodes[0]"
v-for="(nodeData, index) of mappedSelections"
:key="nodeData.id"
>
<div
@@ -274,6 +231,7 @@ defineExpose({ runButtonClick })
</template>
</div>
</Teleport>
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
@@ -284,6 +242,7 @@ defineExpose({ runButtonClick })
class="mt-4 w-full"
/>
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">

View File

@@ -4,6 +4,7 @@ import {
useInfiniteScroll,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ComponentPublicInstance } from 'vue'
import {
computed,
@@ -26,11 +27,13 @@ import type {
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const { hasOutputs } = storeToRefs(useAppModeStore())
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
@@ -156,8 +159,10 @@ watch(
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else {
} else if (hasOutputs.value) {
selectFirstHistory()
} else {
store.selectAsLatest(null)
}
},
{ immediate: true }
@@ -180,13 +185,13 @@ watch(
: undefined
if (!sv || sv.kind !== 'history') {
selectFirstHistory()
if (hasOutputs.value) selectFirstHistory()
return
}
const wasFirst = sv.assetId === oldAssets[0]?.id
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
selectFirstHistory()
if (hasOutputs.value) selectFirstHistory()
}
}
)

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{ title: string; price: string }>()
</script>
<template>
<div class="not-last:mb-4">
<div class="text-muted-foreground" v-text="title" />
<span
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
>
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
{{ price }}
</span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
const { isCreditsBadge } = usePriceBadge()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">
<template #button>
<Button class="mr-2 size-10">
<i class="icon-[comfy--credits] bg-amber-400" />
</Button>
</template>
<section
class="max-h-(--reka-popover-content-available-height) overflow-y-auto"
>
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
/>
</section>
</Popover>
<div v-else-if="creditsBadges.length === 1">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
class="border-t border-border-subtle pt-2"
/>
</div>
<CollapsibleRoot
v-else-if="creditsBadges.length"
v-slot="{ open }"
class="flex max-h-1/2 w-full flex-col"
>
<div class="mb-1 w-full border-b border-border-subtle" />
<CollapsibleTrigger as-child>
<Button variant="textonly" class="w-full text-sm">
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
{{ t('linearMode.hasCreditCost') }}
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="overflow-y-auto">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
/>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -219,6 +219,7 @@ describe(useOutputHistory, () => {
})
it('returns outputs from metadata allOutputs when count matches', () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png'), makeResult('b.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -255,7 +256,7 @@ describe(useOutputHistory, () => {
expect(outputs[0].filename).toBe('b.png')
})
it('returns all outputs when no output nodes are selected', () => {
it('returns empty when no output nodes are selected', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -265,7 +266,7 @@ describe(useOutputHistory, () => {
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(2)
expect(outputs).toHaveLength(0)
})
it('returns consistent filtered outputs across repeated calls', () => {
@@ -288,6 +289,7 @@ describe(useOutputHistory, () => {
})
it('returns in-progress outputs for pending resolve jobs', () => {
useAppModeStore().selectedOutputs.push('1')
pendingResolveRef.value = new Set(['job-1'])
inProgressItemsRef.value = [
{
@@ -314,6 +316,7 @@ describe(useOutputHistory, () => {
})
it('fetches full job detail for multi-output jobs', async () => {
useAppModeStore().selectedOutputs.push('1')
jobDetailResults.set('job-1', {
outputs: {
'1': {
@@ -342,6 +345,7 @@ describe(useOutputHistory, () => {
describe('watchEffect resolve loop', () => {
it('resolves pending jobs when history outputs load', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -360,6 +364,7 @@ describe(useOutputHistory, () => {
})
it('does not select first history when a selection exists', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,

View File

@@ -65,7 +65,7 @@ export function useOutputHistory(): {
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return items
if (!nodeIds.length) return []
return items.filter((r) =>
nodeIds.some((id) => String(id) === String(r.nodeId))
)

View File

@@ -203,7 +203,6 @@ const handleDownload = () => {
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}

View File

@@ -233,7 +233,6 @@ const handleDownload = () => {
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadImage'),
life: 3000,
group: 'image-preview'
})
}

View File

@@ -39,7 +39,8 @@
v-if="
lgraphNode?.constructor?.nodeData?.output_node &&
isSelectOutputsMode &&
nodeData.mode === LGraphEventMode.ALWAYS
nodeData.mode === LGraphEventMode.ALWAYS &&
!nodeData.hasErrors
"
:id="nodeData.id"
/>
@@ -91,10 +92,6 @@
backgroundColor: applyLightThemeColor(nodeData?.color)
}"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"

View File

@@ -109,6 +109,7 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import {
stripGraphPrefix,
useWidgetValueStore
@@ -160,7 +161,11 @@ onErrorCaptured((error) => {
})
const canSelectInputs = computed(
() => isSelectInputsMode.value && nodeData?.mode === LGraphEventMode.ALWAYS
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()

View File

@@ -4,6 +4,8 @@ import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { app } from '@/scripts/app'
@@ -17,6 +19,56 @@ function splitAroundFirstSpace(text: string): [string, string | undefined] {
return [text.slice(0, index), text.slice(index + 1)]
}
type TrackableNode = {
id: NodeId
type: string
inputs?: INodeInputSlot[]
}
//TODO deduplicate reactivity tracking once more thoroughly tested
export function trackNodePrice(node: TrackableNode) {
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(node.id).value
if (!hasDynamicPricing(node.type)) return
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = getRelevantWidgetNames(node.type)
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && node.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, node.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = getInputNames(node.type)
if (inputNames.length > 0) {
node?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = getInputGroupPrefixes(node.type)
if (groupPrefixes.length > 0) {
node?.inputs?.forEach((inp) => {
if (groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))) {
void inp.link // Access link to create reactive dependency
}
})
}
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {

View File

@@ -208,8 +208,7 @@ const handleDownload = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile'),
life: 3000
detail: t('g.failedToDownloadFile')
})
}
}

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