Compare commits

...

25 Commits

Author SHA1 Message Date
jaeone94
bf19a0bc42 fix: address review findings on the resolution view
- Stop collapsing the minimap on entry: it silently persisted the
  user's setting; the panel already dodges an open minimap, and a unit
  test now guards that entry mutates no persisted settings
- Exit the view whenever the workflow returns to app mode, so the
  Toggle App Mode command cannot strand an active view that re-opens
  on the next graph entry
- Snap the carousel using the track's clientWidth: the ResizeObserver
  ref is still 0 on the immediate-watch mount path, which landed the
  snap on slide 0; the unit test now asserts the exact scroll offset
- Announce the resolved state via a persistent sr-only live region;
  a region inserted together with its content is not announced, and
  the back button no longer sits inside it
- Skip FitView when the view was exited during the canvas resize wait
2026-07-07 18:06:36 +09:00
jaeone94
cf4b3ae50b test: update linear mode tests for the Fix errors label 2026-07-07 18:06:09 +09:00
jaeone94
4da3dcd6e3 fix: restyle the resolved state to match the error card look
Center the all-resolved message in a bordered card on the panel
surface, matching the error list container, and tone the back button
down to the secondary variant.
2026-07-07 17:29:02 +09:00
GitHub Action
448ebb6cc6 [automated] Apply ESLint and Oxfmt fixes 2026-07-07 08:14:04 +00:00
jaeone94
c46103687b feat: relabel error view entry buttons to Fix errors
Replace linearMode.viewGraph and linearMode.error.goto with a shared
linearMode.fixErrors key so the app mode banner, mobile error screen,
and error overlay all describe the resolution view they now open.
Dropping the old keys lets other locales fall back to English instead
of showing translations of the outdated labels.
2026-07-07 17:07:15 +09:00
jaeone94
7188ac1e31 feat: polish the resolution panel chrome and layout
Slim the desktop panel to the error list (no duplicate header or
search), give the narrow top bar a hero-style header, and center the
desktop panel in the band above the canvas menu, dodging the minimap
when it is open.
2026-07-07 17:07:15 +09:00
jaeone94
6b62382989 feat: add a carousel layout to ErrorGroupList for the resolution panel
Narrow layouts render error groups as horizontally snapping slides
with a dot indicator. Slides are always expanded (a collapsed slide
reads as an empty card) and selection emphasis snaps to the first
matched slide instead of collapsing the rest.
2026-07-07 17:07:13 +09:00
jaeone94
5c445db96c test: cover error resolution view e2e 2026-07-07 14:43:21 +09:00
jaeone94
742ba0dddd feat: route app mode errors to resolution view
Entering from app mode collapses the minimap and fits the graph once
the canvas has been re-measured; fitting earlier captures a zero-sized
canvas and corrupts the view transform.
2026-07-07 14:43:21 +09:00
jaeone94
81d97858b9 feat: add error resolution overlay and panel
Floating error panel (right card on desktop, collapsible top bar with a
card carousel on narrow screens), resolved state, and a persistent Back
to App Mode button. Desktop placement avoids the minimap and canvas
menu.
2026-07-07 14:43:21 +09:00
jaeone94
5be1fc99f3 feat: hide UI chrome during error resolution
Generalizes focus mode chrome hiding via useChromeVisibility and fixes
the splitter gutter preceding a hidden panel remaining hit-testable at
the screen edge (also affected focus mode).
2026-07-07 14:43:21 +09:00
jaeone94
11beaea704 feat: add error resolution store 2026-07-07 14:43:21 +09:00
jaeone94
df0fe84343 refactor: address review nits on selection emphasis
- Replace the two emphasis class constants with a selectionEmphasisClass(highlighted) helper and update all call sites
- Inline someNodeTypeInSelection at the card bindings instead of wrapper functions
- Extract assetNodeIdsWithError and derive selectionMatchedAssetNodeIds/errorNodeCount from it, dropping the imperative set-building
- Build selection-matched sets via map/filter instead of mutation
- Drop redundant highlighted=false prop defaults and template-only props from destructuring
- Trim justification comments; move e2e comments into expect messages
- Rename the pre-mount emphasis test to describe the collapse behavior it asserts
2026-07-07 13:15:22 +09:00
jaeone94
39d7f7bb7f test: cover selection emphasis collapse cycle e2e
Uses the existing missing_nodes_and_media fixture to assert that
selecting an error node collapses unrelated groups, marks matched rows
via aria-current, and that deselecting restores expansion and the
summary strip.
2026-07-06 19:15:09 +09:00
jaeone94
2db7b477b9 fix: scope the errors list live region to the empty state
The list container's aria-live overlapped the strip's role=status,
double-announcing selection updates and reading whole-list re-renders.
The empty-state message keeps its own status role so clearing the last
error still announces.
2026-07-06 17:57:10 +09:00
jaeone94
8264fb0603 fix: address second review pass on the resident strip
Summary numbers are now consistently workflow-wide: the error half no
longer shrinks with the search query while the node half ignores it,
and node-less errors (prompt-level) fall back to a count-only message
instead of '0 nodes'. The strip mode is decided once in a single
computed so keypath and numbers cannot disagree. ForSelection group
computeds return [] without a selection to match their names, media
row emphasis moved off the TransitionGroup child so list animations
keep working, the ErrorNodeCard emphasis no longer shifts siblings
vertically, and border-radius joins the emphasis transition. The
component test now uses the shared real-locale testI18n.
2026-07-06 17:42:57 +09:00
jaeone94
40eac63410 fix: center selection emphasis bleed on missing-item header rows
The header rows' explicit w-full kept the box at 100% width, so the
negative margins shifted the highlight left instead of widening it.
Flex-column stretch already fills the row, so dropping w-full lets the
background bleed symmetrically.
2026-07-06 17:24:13 +09:00
jaeone94
ae755a2276 feat: animate selection emphasis and keep the context strip resident
Emphasized rows transition their background/margins in both directions
so highlights fade in and out without jumping. The context strip now
always occupies its slot: it reads '{n} nodes — {count} errors' as a
workflow summary and switches to the selection's errors while emphasis
is active, so selecting/deselecting never reflows the list. The
resident strip is also the future home for mixed error/warning counts
once missing-* items are downgraded to warnings.
2026-07-06 17:17:03 +09:00
jaeone94
35dc4eff83 fix: bleed selection emphasis background past row content
Highlighted rows keep their text exactly in place (negative margin
cancelled by matching padding) while the background extends 6px past
the content edge, restoring breathing room. Wrappers that clip via
overflow-hidden get the same cancelling pair so the bleed survives.
2026-07-06 17:03:23 +09:00
jaeone94
8e90b33fd3 fix: address review findings for selection emphasis
- Apply emphasis immediately for selections that predate mount and
  reset stale collapse state for groups that disappeared mid-emphasis
- Restore expansion via the watcher's previous value instead of a
  mutable flag; read matched keys directly instead of re-parsing the
  change-detection signature
- Render the strip node title through i18n-t slots so vue-i18n's
  escapeParameter no longer HTML-escapes titles like 'a = b'; fall back
  to Untitled for unnamed single-node selections; announce the strip
  via role=status
- Replace the near-invisible primary/10 tint with the design-system
  blue-selection token, use primary-background-hover for the strip
  label (>=4.5:1 in both themes), share one emphasis class constant,
  and expose emphasis to AT via aria-current
- Consolidate the nodeType-in-selection predicate, drop the dead
  no-selection branch of selectionScopedGroups, merge the scoped
  missing-node builder into buildMissingNodeGroups, and rename the
  Filtered builders to ForSelection
- Remove the orphaned ErrorNodeCard compact prop and the carousel/
  showSearch machinery (ships with the error resolution view PR)
- Cover the emphasis watcher, strip labels, and subgraph matching with
  component/unit tests; assert strip content in e2e
2026-07-06 16:51:39 +09:00
jaeone94
95a7e43f61 fix: balance selection strip spacing against hero and items 2026-07-06 15:53:41 +09:00
jaeone94
237432ef26 fix: refine selection emphasis visuals
Missing model/node/swap rows now emphasize the header row with a
background tint instead of an outline ring that clipped inside
overflow-hidden containers. The selection context strip drops the hero
background and locate icon, uses the highlight color for its label, and
animates in and out.
2026-07-06 15:50:21 +09:00
jaeone94
b8d643ca82 feat: extend selection emphasis to missing asset rows
Highlights the missing model/media/node/swap rows that reference the
selected node, narrows the missing-node and swap-node emphasis counts to
packs containing the selection, and re-expands all groups when the
selection emphasis ends.
2026-07-06 15:35:35 +09:00
jaeone94
f088382e3d feat: keep errors tab global under node selection
Selecting nodes no longer filters the errors tab. The hero count and
group list always describe the whole workflow; selection instead
emphasizes its errors — matching groups auto-expand while others
collapse, matching cards are highlighted, and a context strip names the
selection. Prevents a selection from masking remaining errors as
resolved.
2026-07-06 15:20:39 +09:00
jaeone94
49d8dc47f3 refactor: extract ErrorGroupList from TabErrors 2026-07-06 14:52:46 +09:00
41 changed files with 2425 additions and 630 deletions

View File

@@ -110,7 +110,8 @@ export const TestIds = {
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
errorsTab: 'panel-tab-errors',
selectionContextStrip: 'selection-context-strip'
},
assets: {
browserModal: 'asset-browser-modal',
@@ -166,6 +167,10 @@ export const TestIds = {
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
errorResolution: {
panel: 'error-resolution-panel',
back: 'error-resolution-back'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',

View File

@@ -61,10 +61,10 @@ test.describe(
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
comfyPage.page.getByTestId(TestIds.errorResolution.back)
).toBeVisible()
})

View File

@@ -0,0 +1,191 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
},
{
type: 'value_smaller_than_min',
message: 'Value -1 smaller than min of 0',
details: '',
extra_info: { input_name: 'quality' }
}
]
}
}
test.describe('Error resolution view', { tag: ['@ui', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
})
test('shows canvas with error panel and hides UI chrome', async ({
comfyPage
}) => {
await expect(comfyPage.canvas).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.back)
).toBeVisible()
await expect(comfyPage.menu.sideToolbar).toBeHidden()
// FitView on entry must wait for the canvas to be re-measured; fitting
// a zero-sized canvas corrupts the view transform (scale 0 / NaN)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const { scale, offset } = window.app!.canvas.ds
return (
Number.isFinite(scale) &&
scale > 0.01 &&
Number.isFinite(offset[0]) &&
Number.isFinite(offset[1])
)
})
)
.toBe(true)
})
test('back button returns to app mode and restores chrome on next graph entry', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.errorResolution.back).click()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeHidden()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('locating an error node keeps the panel open', async ({ comfyPage }) => {
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
await panel
.getByRole('button', { name: /locate/i })
.first()
.click()
await comfyPage.nextFrame()
await expect(panel).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.back)
).toBeVisible()
})
test('narrow viewport shows collapsible top bar with card carousel', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 375, height: 812 })
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
await expect(panel).toBeVisible()
await expect(panel.getByTestId(TestIds.errorResolution.back)).toBeVisible()
const errorCards = panel.getByTestId('error-group-execution')
await expect(errorCards.first()).toBeVisible()
// The summary hero is replaced by the top bar count in carousel layout
await expect(panel.getByTestId('errors-summary-hero')).toBeHidden()
// Two catalog groups → two carousel slides with position dots
await expect(errorCards).toHaveCount(2)
const dots = panel.getByTestId('error-carousel-dots').getByRole('button')
await expect(dots).toHaveCount(2)
await expect(dots.first()).toHaveAttribute('aria-current', 'true')
// Each slide spans the full track width (one card per view)
const cardBox = await errorCards.first().boundingBox()
const panelBox = await panel.boundingBox()
expect(cardBox!.width).toBeGreaterThan(panelBox!.width * 0.8)
// Clicking a dot swipes to that slide
await dots.nth(1).click()
await expect(dots.nth(1)).toHaveAttribute('aria-current', 'true')
await panel.getByRole('button', { name: /hide errors/i }).click()
await expect(errorCards.first()).toBeHidden()
await panel.getByRole('button', { name: /show errors/i }).click()
await expect(errorCards.first()).toBeVisible()
})
test('desktop panel does not cover the minimap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
const minimap = comfyPage.page.getByTestId('minimap-container')
await expect(panel).toBeVisible()
await expect(minimap).toBeVisible()
const panelBox = await panel.boundingBox()
const minimapBox = await minimap.boundingBox()
expect(panelBox).not.toBeNull()
expect(minimapBox).not.toBeNull()
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(minimapBox!.y)
})
test('desktop panel does not cover the canvas menu when minimap is hidden', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
const panel = comfyPage.page.getByTestId(TestIds.errorResolution.panel)
const minimapToggle = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(panel).toBeVisible()
await expect(minimapToggle).toBeVisible()
const panelBox = await panel.boundingBox()
const toggleBox = await minimapToggle.boundingBox()
expect(panelBox).not.toBeNull()
expect(toggleBox).not.toBeNull()
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(toggleBox!.y)
})
test('right screen edge has no splitter gutter hit area', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.errorResolution.panel)
).toBeVisible()
const edgeHitsGutter = await comfyPage.page.evaluate(() => {
const x = window.innerWidth - 1
for (const y of [100, window.innerHeight / 2, window.innerHeight - 100]) {
const el = document.elementFromPoint(x, y)
if (el?.closest('.p-splitter-gutter')) return true
}
return false
})
expect(edgeHitsGutter).toBe(false)
})
})

View File

@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
test('Selecting a node keeps all errors visible and shows selection context', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -301,14 +301,25 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expectReferenceBadge(missingModelGroup, 2)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
strip,
'The strip count is scoped to the selection, diverging from the global reference badge'
).toContainText('1 error')
await comfyPage.canvas.click()
await expect(
strip,
'Deselecting swaps the always-visible strip back to the summary'
).toContainText('2 nodes — 1 error')
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -381,7 +392,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
test('Selecting a node keeps all media rows visible and shows selection context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
@@ -403,13 +414,66 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await expect(mediaRows).toHaveCount(1)
// Selection no longer filters the list — rows stay global and the
// selection is surfaced via the context strip instead.
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(strip).toContainText('1 error')
await expect(mediaRows).toHaveCount(2)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Deselecting swaps the always-visible strip back to the summary
await expect(strip).toContainText('2 nodes — 2 errors')
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Selection emphasis', () => {
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_and_media'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const mediaRow = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).toBeVisible()
await expect(strip).toContainText('2 nodes — 2 errors')
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
// The node sits near the canvas top where overlays intercept clicks
await mediaNode.centerOnNode()
await mediaNode.click('title')
// The unrelated missing-node group auto-collapses while the matched
// media row stays visible and is marked as part of the selection
await expect(missingNodeCard).toBeHidden()
await expect(mediaRow).toBeVisible()
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('1 error')
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Emphasis ends: the collapsed group re-expands and the strip
// returns to the workflow summary
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('2 nodes — 2 errors')
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)

View File

@@ -70,7 +70,7 @@
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
!(bottomPanelVisible && !isChromeHidden) && 'hidden'
)
"
state-key="bottom-panel-splitter"
@@ -81,7 +81,7 @@
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
v-show="bottomPanelVisible && !isChromeHidden"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
>
<slot name="bottom-panel" />
@@ -131,6 +131,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
@@ -141,9 +142,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
@@ -156,7 +155,7 @@ const unifiedWidth = computed(() =>
settingStore.get('Comfy.Sidebar.UnifiedWidth')
)
const { focusMode } = storeToRefs(workspaceStore)
const { isChromeHidden } = useChromeVisibility()
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
@@ -189,7 +188,9 @@ const lastPanelVisible = computed(
*/
const bothSidePanelsVisible = computed(
() =>
!focusMode.value && sidebarPanelVisible.value && showOffsideSplitter.value
!isChromeHidden.value &&
sidebarPanelVisible.value &&
showOffsideSplitter.value
)
const centerPanelDefaultSize = computed(() =>
@@ -264,7 +265,7 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (isChromeHidden.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -272,7 +273,7 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (isChromeHidden.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -296,7 +297,9 @@ const lastPanelStyle = computed(() => {
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
.p-splitter-gutter:has(
+ [data-pc-name='splitterpanel'][style*='display: none']
)
) {
display: none;
}

View File

@@ -1,6 +1,6 @@
<template>
<div
v-show="workspaceState.focusMode"
v-show="workspaceState.focusMode && !errorResolutionStore.isActive"
class="no-drag fixed top-0 right-0 z-9999 flex flex-row"
>
<Button
@@ -24,10 +24,12 @@ import { watchEffect } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const errorResolutionStore = useErrorResolutionStore()
const settingStore = useSettingStore()
const exitFocusMode = () => {
workspaceState.focusMode = false

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="!workspaceStore.focusMode"
v-if="!isChromeHidden"
class="ml-1 flex flex-col gap-1 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
@@ -145,6 +145,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -154,7 +155,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
@@ -168,7 +168,7 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { cn } from '@comfyorg/tailwind-utils'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isChromeHidden } = useChromeVisibility()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()

View File

@@ -81,9 +81,7 @@ function createTestI18n() {
viewDetails: 'View details'
},
linearMode: {
error: {
goto: 'Show errors in graph'
}
fixErrors: 'Fix errors'
}
}
}
@@ -196,7 +194,7 @@ describe('ErrorOverlay', () => {
await nextTick()
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'Show errors in graph'
'Fix errors'
)
})
})

View File

@@ -41,7 +41,7 @@
>
{{
appMode
? t('linearMode.error.goto')
? t('linearMode.fixErrors')
: t('errorOverlay.viewDetails')
}}
</Button>

View File

@@ -0,0 +1,47 @@
<template>
<template v-if="isVisible">
<Button
v-if="!isNarrow"
data-testid="error-resolution-back"
variant="secondary"
size="lg"
class="fixed top-2 left-2 z-1000"
@click="backToAppMode"
>
<i class="icon-[lucide--arrow-left] size-4" />
{{ t('errorResolution.backToApp') }}
</Button>
<ErrorResolutionPanel @back="backToAppMode" />
</template>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const ErrorResolutionPanel = defineAsyncComponent(
() => import('@/components/errorResolution/ErrorResolutionPanel.vue')
)
const { t } = useI18n()
const canvasStore = useCanvasStore()
const errorResolutionStore = useErrorResolutionStore()
const workspaceStore = useWorkspaceStore()
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
const isVisible = computed(
() => errorResolutionStore.isActive && !canvasStore.linearMode
)
function backToAppMode() {
errorResolutionStore.exit()
workspaceStore.focusMode = false
canvasStore.linearMode = true
}
</script>

View File

@@ -0,0 +1,119 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorResolutionPanel from './ErrorResolutionPanel.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: vi.fn()
}))
}))
describe('ErrorResolutionPanel.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorResolution: {
title: 'Fix workflow errors',
backToApp: 'Back to App Mode',
allResolved: 'All errors resolved',
allResolvedDesc: 'You are ready to go back to App Mode.',
showErrors: 'Show errors',
hideErrors: 'Hide errors'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorsDetected: 'Error detected | Errors detected',
resolveBeforeRun: 'Resolve before running the workflow',
expand: 'Expand',
collapse: 'Collapse'
}
}
}
})
})
function renderComponent(initialState = {}) {
const user = userEvent.setup()
const result = render(ErrorResolutionPanel, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
AsyncSearchInput: {
template: '<input />'
}
}
}
})
return { user, ...result }
}
it('shows the resolved state with a back button when no errors exist', async () => {
const { user, emitted } = renderComponent()
expect(
screen.getByRole('status'),
'the resolved transition is announced via the persistent live region'
).toHaveTextContent('All errors resolved')
expect(screen.getAllByText('All errors resolved')).not.toHaveLength(0)
expect(screen.queryByTestId('errors-summary-hero')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Back to App Mode/i }))
expect(emitted('back')).toHaveLength(1)
})
it('shows the error list when errors exist', () => {
renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
expect(screen.getByTestId('errors-summary-hero')).toBeInTheDocument()
expect(screen.queryByText('All errors resolved')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,161 @@
<template>
<section
data-testid="error-resolution-panel"
:class="
cn(
'pointer-events-auto fixed z-1000 flex flex-col overflow-hidden border-interface-stroke bg-base-background',
isNarrow
? 'inset-x-0 top-0 border-b'
: cn(
// Centered in the band above the canvas menu (or the open
// minimap): equal 10%-of-band gaps top and bottom
'right-1 w-90 max-w-[calc(100vw-0.5rem)] rounded-lg border shadow-interface',
isMinimapVisible
? 'top-[calc((100%-258px)/10)] bottom-[calc(258px+(100%-258px)/10)]'
: 'top-[calc((100%-58px)/10)] bottom-[calc(58px+(100%-58px)/10)]'
)
)
"
:aria-label="t('errorResolution.title')"
>
<!-- Persistent live region: one inserted with its content is not announced -->
<span role="status" class="sr-only">
{{ isResolved ? t('errorResolution.allResolved') : '' }}
</span>
<div
v-if="isNarrow"
class="flex min-w-0 shrink-0 items-center gap-2 bg-base-foreground/5 p-2"
>
<Button
data-testid="error-resolution-back"
variant="base"
size="icon"
class="shrink-0 border border-interface-stroke"
:aria-label="t('errorResolution.backToApp')"
@click="emit('back')"
>
<i class="icon-[lucide--arrow-left] size-4" />
</Button>
<template v-if="isResolved">
<i
aria-hidden="true"
class="icon-[lucide--circle-check] size-5 shrink-0 text-success-background"
/>
<span class="min-w-0 flex-1 truncate text-sm font-semibold">
{{ t('errorResolution.allResolved') }}
</span>
</template>
<template v-else>
<span
class="flex h-10 min-w-7 shrink-0 items-center justify-center px-1 text-2xl/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-8 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5 px-1">
<span
class="truncate text-xs/tight font-semibold text-base-foreground"
>
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="truncate text-2xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</template>
<Button
variant="textonly"
size="icon"
class="shrink-0"
:aria-label="
isExpanded
? t('errorResolution.hideErrors')
: t('errorResolution.showErrors')
"
:aria-expanded="isExpanded"
@click="isExpanded = !isExpanded"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-up]'
: 'icon-[lucide--chevron-down]'
)
"
/>
</Button>
</div>
<TransitionCollapse>
<div v-if="!isNarrow || isExpanded" class="flex min-h-0 flex-1 flex-col">
<div
v-if="isResolved"
class="flex min-h-0 flex-1 flex-col bg-interface-panel-surface p-3"
>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border border-secondary-background px-6 py-8 text-center"
>
<i
aria-hidden="true"
class="icon-[lucide--circle-check] size-10 text-success-background"
/>
<p class="m-0 text-sm font-semibold text-base-foreground">
{{ t('errorResolution.allResolved') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ t('errorResolution.allResolvedDesc') }}
</p>
<Button variant="secondary" class="mt-2" @click="emit('back')">
<i class="icon-[lucide--arrow-left] size-4" />
{{ t('errorResolution.backToApp') }}
</Button>
</div>
</div>
<ErrorGroupList
v-else
:show-search="false"
:carousel="isNarrow"
class="min-h-0 flex-1"
/>
</div>
</TransitionCollapse>
</section>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import ErrorGroupList from '@/components/rightSidePanel/errors/ErrorGroupList.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import { useSettingStore } from '@/platform/settings/settingStore'
const emit = defineEmits<{
back: []
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const isNarrow = useBreakpoints(breakpointsTailwind).smaller('md')
const isExpanded = ref(true)
const isMinimapVisible = computed(() =>
settingStore.get('Comfy.Minimap.Visible')
)
const { allErrorGroups } = useErrorGroups('')
const totalErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const isResolved = computed(() => allErrorGroups.value.length === 0)
</script>

View File

@@ -153,6 +153,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
@@ -239,9 +240,8 @@ const selectionToolboxEnabled = computed(() =>
const activeSidebarTab = computed(() => {
return workspaceStore.sidebarTab.activeSidebarTab
})
const showUI = computed(
() => !workspaceStore.focusMode && betaMenuEnabled.value
)
const { isChromeHidden } = useChromeVisibility()
const showUI = computed(() => !isChromeHidden.value && betaMenuEnabled.value)
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))

View File

@@ -1,12 +1,19 @@
<template>
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
<div class="flex min-h-8 w-full items-center gap-2 px-3">
<button
type="button"
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
@click="collapse = !collapse"
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:class="
cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left',
collapsible &&
'focus-visible:ring-ring cursor-pointer outline-none focus-visible:ring-1'
)
"
:aria-expanded="collapsible ? !collapse : undefined"
:aria-controls="collapsible ? bodyId : undefined"
@click="collapsible && (collapse = !collapse)"
>
<span
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
@@ -16,9 +23,10 @@
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ title }}
</span>
</button>
</component>
<slot name="actions" />
<button
v-if="collapsible"
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
@@ -40,7 +48,7 @@
</button>
</div>
<TransitionCollapse>
<div v-if="!collapse" :id="bodyId">
<div v-if="!(collapsible && collapse)" :id="bodyId">
<slot />
</div>
</TransitionCollapse>
@@ -57,10 +65,13 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
const {
title,
count,
collapsible = true,
class: className
} = defineProps<{
title: string
count: number
/** When false, the section always renders expanded with no toggle UI. */
collapsible?: boolean
class?: string
}>()

View File

@@ -0,0 +1,294 @@
import { createTestingPinia } from '@pinia/testing'
import type { TestingPinia } from '@pinia/testing'
import { render, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { fromAny } from '@total-typescript/shoehorn'
import ErrorGroupList from './ErrorGroupList.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: vi.fn()
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
const SAMPLER_NODE = { id: '1', title: 'SamplerNode' }
const LOADER_NODE = { id: '2', title: 'LoaderNode' }
function seedTwoErrorGroups(pinia: TestingPinia) {
const executionErrorStore = useExecutionErrorStore(pinia)
executionErrorStore.lastNodeErrors = fromAny<
typeof executionErrorStore.lastNodeErrors,
unknown
>({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: '',
extra_info: { input_name: 'clip' }
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'weird_error', message: 'Something odd happened', details: '' }
]
}
})
}
function renderList(pinia: TestingPinia, props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const { rerender } = render(ErrorGroupList, {
props,
global: {
plugins: [PrimeVue, testI18n, pinia],
stubs: {
AsyncSearchInput: {
template: '<input />'
}
}
}
})
return { user, rerender }
}
function createPinia() {
return createTestingPinia({ createSpy: vi.fn, stubActions: false })
}
function getSectionByTitle(title: string) {
const sections = screen.getAllByTestId('error-group-execution')
const section = sections.find((s) => within(s).queryByText(title))
expect(section).toBeDefined()
return section!
}
function isSectionExpanded(section: HTMLElement) {
const [header] = within(section).getAllByRole('button', { hidden: true })
return header.getAttribute('aria-expanded') === 'true'
}
describe('ErrorGroupList selection emphasis', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? SAMPLER_NODE : LOADER_NODE
)
)
})
it('expands matched groups, collapses others, and restores on deselect', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const samplerSection = getSectionByTitle('Missing connection')
const loaderSection = getSectionByTitle('Validation failed')
expect(isSectionExpanded(samplerSection)).toBe(true)
expect(isSectionExpanded(loaderSection)).toBe(true)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(false)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
canvasStore.selectedItems = []
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(true)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
})
it('expands only matched groups for a selection that predates mount', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const canvasStore = useCanvasStore(pinia)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
renderList(pinia)
await waitFor(() => {
expect(isSectionExpanded(getSectionByTitle('Validation failed'))).toBe(
false
)
})
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('leaves manual collapse state alone for selections without errors', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const { user } = renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const loaderSection = getSectionByTitle('Validation failed')
const [loaderHeader] = within(loaderSection).getAllByRole('button')
await user.click(loaderHeader)
expect(isSectionExpanded(loaderSection)).toBe(false)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99', title: 'Unrelated' }])
await waitFor(() => {
// No emphasis: the strip falls back to the workflow summary
expect(screen.getByTestId('selection-context-strip')).toHaveTextContent(
'2 nodes — 2 errors'
)
})
expect(isSectionExpanded(loaderSection)).toBe(false)
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('always shows the strip: workflow summary by default, selection while emphasized', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const strip = screen.getByTestId('selection-context-strip')
expect(strip).toHaveTextContent('2 nodes — 2 errors')
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('SamplerNode — 1 error')
})
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE, LOADER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes selected — 2 errors')
})
canvasStore.selectedItems = []
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes — 2 errors')
})
})
it('carousel ignores collapse state, renders no toggles, and snaps to matches', async () => {
const scrollTo = vi
.spyOn(Element.prototype, 'scrollTo')
.mockImplementation(() => {})
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const { user, rerender } = renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const loaderSection = getSectionByTitle('Validation failed')
const [loaderHeader] = within(loaderSection).getAllByRole('button')
await user.click(loaderHeader)
expect(isSectionExpanded(loaderSection)).toBe(false)
await rerender({ carousel: true })
expect(
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
'the carousel ignores collapse state carried over from the list'
).toBeInTheDocument()
expect(
screen.queryByLabelText('Collapse'),
'the carousel renders no collapse affordances'
).not.toBeInTheDocument()
expect(screen.queryByLabelText('Expand')).not.toBeInTheDocument()
const sections = screen.getAllByTestId('error-group-execution')
const matchedIndex = 1
const nodeInSecondSlide = within(sections[matchedIndex]).queryByText(
'Missing connection'
)
? SAMPLER_NODE
: LOADER_NODE
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([nodeInSecondSlide])
await waitFor(() => {
expect(
scrollTo,
'selection snaps to the matched slide, not slide 0'
).toHaveBeenCalledWith(
expect.objectContaining({ left: matchedIndex * 8 })
)
})
expect(
within(getSectionByTitle('Validation failed')).getByText('LoaderNode'),
'slides stay expanded instead of collapsing'
).toBeInTheDocument()
scrollTo.mockRestore()
})
})

View File

@@ -0,0 +1,729 @@
<template>
<div class="flex min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
v-if="showSearch"
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && allErrorGroups.length > 1"
/>
</div>
<div class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3">
<div
v-if="filteredGroups.length === 0"
role="status"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
:class="
cn(
!carousel &&
'overflow-hidden rounded-lg border border-secondary-background'
)
"
>
<!-- Errors summary hero -->
<div
v-if="!carousel"
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Context strip: workflow summary, or the selection's errors -->
<div
data-testid="selection-context-strip"
role="status"
:class="
cn(
'flex items-center px-3',
carousel
? 'mb-2 rounded-md border border-secondary-background py-1.5'
: 'border-t border-secondary-background pt-3.5 pb-1.5'
)
"
>
<i18n-t
:keypath="strip.keypath"
:plural="strip.count"
tag="span"
:class="
cn(
'min-w-0 flex-1 truncate text-xs font-semibold transition-colors duration-200',
hasSelectionEmphasis
? 'text-primary-background-hover'
: 'text-muted-foreground'
)
"
>
<template #node>{{ selectionStripNodeLabel }}</template>
<template #nodes>{{ strip.nodes }}</template>
<template #count>{{ strip.count }}</template>
</i18n-t>
</div>
<!-- Group by Class Type -->
<div
ref="carouselRef"
:class="
cn(
carousel && 'scrollbar-hide snap-x snap-mandatory overflow-x-auto'
)
"
>
<TransitionGroup
tag="div"
name="list-scale"
:class="cn('relative', carousel && 'flex gap-2')"
>
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapsible="!carousel"
:collapse="
!carousel && isSectionCollapsed(group.groupKey) && !isSearching
"
:class="
cn(
carousel
? 'max-h-[30vh] w-full shrink-0 snap-center overflow-y-auto rounded-lg border border-secondary-background'
: 'border-t border-secondary-background first:border-t-0'
)
"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
:aria-current="
isCardInSelection(item.cardId) ? 'true' : undefined
"
:class="
cn(
'min-w-0',
selectionEmphasisClass(isCardInSelection(item.cardId))
)
"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="
isExecutionItemDetailExpanded(item.key)
"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.label
})
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div
v-else-if="group.type === 'execution'"
class="space-y-3 px-3"
>
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:aria-current="
isCardInSelection(card.id) ? 'true' : undefined
"
:class="
cn(
selectionEmphasisClass(isCardInSelection(card.id)),
isCardInSelection(card.id) && '-my-1 py-1'
)
"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
<!-- Carousel position indicator -->
<div
v-if="carousel && filteredGroups.length > 1"
data-testid="error-carousel-dots"
class="flex justify-center pt-0.5"
>
<button
v-for="(group, index) in filteredGroups"
:key="group.groupKey"
type="button"
:aria-label="group.displayTitle"
:aria-current="index === activeSlide ? 'true' : undefined"
class="flex size-6 cursor-pointer appearance-none items-center justify-center border-0 bg-transparent p-0"
@click="scrollToSlide(index)"
>
<span
:class="
cn(
'rounded-full transition-all',
index === activeSlide ? 'size-2' : 'size-1.5',
hasSelectionEmphasis &&
selectionMatchedGroupKeys.has(group.groupKey)
? 'bg-primary-background-hover'
: index === activeSlide
? 'bg-base-foreground'
: 'bg-base-foreground/30'
)
"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { selectionEmphasisClass } from './selectionEmphasis'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
cardId: string
nodeId: string
label: string
displayDetails?: string
}
const { showSearch = true, carousel } = defineProps<{
showSearch?: boolean
/** Render error groups as horizontally swipeable cards (narrow screens). */
carousel?: boolean
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
cardId: card.id,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
filteredGroups,
collapseState,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const hasSelectionEmphasis = computed(
() => hasSelection.value && selectionErrorCount.value > 0
)
const selectionStripNodeLabel = computed(
() => selectedNodeTitle.value ?? t('g.untitled')
)
// The strip is a status line, not a view of the current filter — summary
// numbers are workflow-wide, never search-filtered.
const workflowErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const strip = computed(() => {
if (hasSelectionEmphasis.value) {
return {
keypath:
selectedNodeCount.value === 1
? 'rightSidePanel.selectedNodeErrors'
: 'rightSidePanel.selectedNodesErrors',
nodes: selectedNodeCount.value,
count: selectionErrorCount.value
}
}
return {
keypath:
errorNodeCount.value === 0
? // Node-less errors (e.g. prompt-level) would read as "0 nodes"
'rightSidePanel.errorsSummary'
: errorNodeCount.value === 1
? 'rightSidePanel.errorNodeSummary'
: 'rightSidePanel.errorNodesSummary',
nodes: errorNodeCount.value,
count: workflowErrorCount.value
}
})
// Matches the carousel track's gap-2 (8px) between slides
const CAROUSEL_SLIDE_GAP_PX = 8
const carouselRef = useTemplateRef<HTMLElement>('carouselRef')
const { x: carouselScrollX } = useScroll(carouselRef)
const { width: carouselWidth } = useElementSize(carouselRef)
const activeSlide = computed(() => {
if (carouselWidth.value === 0) return 0
const stride = carouselWidth.value + CAROUSEL_SLIDE_GAP_PX
return Math.max(
0,
Math.min(
filteredGroups.value.length - 1,
Math.round(carouselScrollX.value / stride)
)
)
})
function scrollToSlide(index: number) {
const track = carouselRef.value
if (!track) return
// clientWidth, not the ResizeObserver-driven carouselWidth: on the
// immediate-watch mount path the observer has not delivered yet
track.scrollTo({
left: index * (track.clientWidth + CAROUSEL_SLIDE_GAP_PX),
behavior: 'smooth'
})
}
function isCardInSelection(cardId: string): boolean {
return selectionMatchedCardIds.value.has(cardId)
}
/**
* Dedupes the Set-valued computed (fresh reference per recompute) so the
* emphasis watcher below only fires when the matched membership changes.
*/
const selectionEmphasisSignature = computed(() =>
hasSelection.value
? Array.from(selectionMatchedGroupKeys.value).sort().join('\n')
: ''
)
// Emphasis per layout: the list expands matched groups and collapses the
// rest; the carousel never collapses (a collapsed slide reads as an empty
// card) and snaps to the first matched slide instead.
watch(
selectionEmphasisSignature,
(signature, previousSignature) => {
if (!signature) {
if (!previousSignature) return
// Restore regardless of layout: collapse state persists across the
// carousel (which ignores it), so a stale emphasis must not resurface
// when the panel returns to the list layout.
for (const groupKey of Object.keys(collapseState)) {
setSectionCollapsed(groupKey, false)
}
return
}
const matchedKeys = selectionMatchedGroupKeys.value
if (carousel) {
const matchedIndex = filteredGroups.value.findIndex((group) =>
matchedKeys.has(group.groupKey)
)
if (matchedIndex < 0) return
// nextTick: with immediate:true the carousel element isn't mounted yet
void nextTick(() => scrollToSlide(matchedIndex))
return
}
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
}
},
{ immediate: true }
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -1,9 +1,6 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div
v-if="card.nodeId && !compact"
class="flex min-h-8 flex-wrap items-center gap-2"
>
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
@@ -103,7 +100,7 @@
<TransitionCollapse>
<div
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
v-if="error.isRuntimeError && runtimeDetailsExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
@@ -186,9 +183,8 @@ import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const { card, compact = false } = defineProps<{
const { card } = defineProps<{
card: ErrorCardData
compact?: boolean
}>()
const emit = defineEmits<{
@@ -203,9 +199,6 @@ const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))

View File

@@ -56,12 +56,15 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden">
<div class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:highlighted="
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
@@ -106,10 +109,13 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
missingPackGroups: MissingPackGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
@@ -216,6 +224,8 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from './selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -227,9 +237,11 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton } = defineProps<{
const { group, showInfoButton, highlighted } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
/** Emphasize the header row (pack containing the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,275 +1,6 @@
<template>
<div class="flex h-full min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && tabErrorGroups.length > 1"
/>
</div>
<div
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
aria-live="polite"
>
<div
v-if="filteredGroups.length === 0"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Group by Class Type -->
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
<ErrorGroupList class="min-h-0 flex-1" />
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
@@ -308,44 +39,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
import ErrorGroupList from './ErrorGroupList.vue'
import { useErrorActions } from './useErrorActions'
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
@@ -355,171 +56,5 @@ const ErrorPanelSurveyCta =
: undefined
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of tabErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -0,0 +1,30 @@
import { cn } from '@comfyorg/tailwind-utils'
import type { MissingNodeType } from '@/types/comfy'
// The negative margin and matching padding cancel out, so the background
// bleeds 6px past the content without shifting the text.
const EMPHASIS_CLASS = 'rounded-sm bg-blue-selection -mx-1.5 px-1.5'
// Present even when unhighlighted so the emphasis animates both ways.
const TRANSITION_CLASS =
'transition-[background-color,margin,padding,border-radius] duration-200'
/** Classes emphasizing rows/cards that belong to the canvas selection. */
export function selectionEmphasisClass(highlighted: boolean | undefined) {
return cn(TRANSITION_CLASS, highlighted && EMPHASIS_CLASS)
}
/** True when any node type resolves to a node in the given id set. */
export function someNodeTypeInSelection(
nodeTypes: MissingNodeType[],
nodeIds: Set<string> | undefined
): boolean {
if (!nodeIds?.size) return false
return nodeTypes.some(
(nodeType) =>
typeof nodeType !== 'string' &&
nodeType.nodeId != null &&
nodeIds.has(String(nodeType.nodeId))
)
}

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
vi.mock('@/scripts/app', () => ({
app: {
@@ -126,6 +127,12 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import {
getExecutionIdByNode,
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
@@ -205,6 +212,7 @@ describe('useErrorGroups', () => {
setActivePinia(createPinia())
mockIsCloud.value = false
vi.mocked(isLGraphNode).mockReturnValue(false)
vi.mocked(getNodeByExecutionId).mockReset()
})
describe('missingPackGroups', () => {
@@ -986,24 +994,13 @@ describe('useErrorGroups', () => {
})
})
describe('unfiltered vs selection-filtered model/media groups', () => {
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups).toBeDefined()
expect(groups.filteredMissingModelGroups).toBeDefined()
expect(groups.missingMediaGroups).toBeDefined()
expect(groups.filteredMissingMediaGroups).toBeDefined()
})
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
describe('selection does not shrink displayed groups', () => {
it('missingModelGroups returns total candidates regardless of selection', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
])
// Simulate canvas selection of a single node so the filtered
// variant actually narrows. Without this, both sides return the
// same value trivially and the test can't prove the contract.
vi.mocked(isLGraphNode).mockReturnValue(true)
const canvasStore = useCanvasStore()
canvasStore.selectedItems = fromAny<
@@ -1012,23 +1009,18 @@ describe('useErrorGroups', () => {
>([{ id: '1' }])
await nextTick()
// Unfiltered total stays at one group of two models regardless of
// the selection — ErrorOverlay reads this for the overlay label
// and must not shrink with canvas selection.
// Displayed groups never shrink with canvas selection — the count
// and list always describe the whole workflow.
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
// Filtered variant does narrow under the same selection state —
// this is how the errors tab scopes cards to the selected node.
// Exact filtered output depends on the app.rootGraph lookup
// (mocked to return undefined here); what matters is that the
// filtered shape is a different reference and does not blindly
// mirror the unfiltered one.
expect(groups.filteredMissingModelGroups.value).not.toBe(
groups.missingModelGroups.value
)
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
?.count
).toBe(2)
})
})
describe('missing media counting', () => {
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
@@ -1051,8 +1043,8 @@ describe('useErrorGroups', () => {
})
})
describe('tabErrorGroups', () => {
it('filters prompt error when a node is selected', async () => {
describe('selection emphasis', () => {
it('never marks workflow-level prompt errors as matched by a selection', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
@@ -1067,11 +1059,205 @@ describe('useErrorGroups', () => {
}
await nextTick()
const promptGroup = groups.tabErrorGroups.value.find(
const promptGroup = groups.allErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeUndefined()
expect(promptGroup).toBeDefined()
expect(
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
).toBe(false)
})
it('reports no selection state when nothing is selected', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(false)
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
expect(groups.selectionErrorCount.value).toBe(0)
})
it('matches groups and cards of the selected error node', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
const selectedNode = { id: '1' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(true)
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
// Both error groups remain displayed regardless of the selection
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
const displayedCardIds = executionGroups.flatMap((g) =>
g.type === 'execution' ? g.cards.map((c) => c.id) : []
)
expect(displayedCardIds).toContain('node-1')
expect(displayedCardIds).toContain('node-2')
})
it('narrows missing-node emphasis to packs containing the selected node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '2' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
// Emphasis counts only the packs containing the selected node…
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
true
)
expect(groups.selectionErrorCount.value).toBe(1)
// …and marks only the selected node for row highlighting.
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
// Display still shows every pack.
const missingNodeGroup = groups.filteredGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingNodeGroup?.count).toBe(2)
})
it('does not emphasize missing-node groups for unrelated selections', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
])
await nextTick()
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
false
)
expect(groups.selectionErrorCount.value).toBe(0)
// Display is unaffected by the unrelated selection.
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
?.count
).toBe(1)
})
it('matches errors through graph resolution, not raw execution ids', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// The error is keyed by a subgraph execution id ('2:5') that resolves
// to a different graph node id ('7') at the current graph level.
const selectedNode = { id: '7' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '2:5' ? selectedNode : undefined
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
})
it('matches interior errors when a subgraph container is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// A container selection matches interior errors by execution-id prefix,
// even when the interior node does not resolve at the current level.
const containerNode = fromAny<SubgraphNode, unknown>(
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
)
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
vi.mocked(getExecutionIdByNode).mockReturnValue(
fromAny<NodeExecutionId, unknown>('2')
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([containerNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'9': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
})
})
})

View File

@@ -24,6 +24,7 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
import { someNodeTypeInSelection } from './selectionEmphasis'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
@@ -259,12 +260,25 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerExecutionIds.size === 0
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
const selectedNodeCount = computed(
() => selectedNodeInfo.value.nodeIds?.size ?? 0
)
const selectedNodeTitle = computed(() => {
if (selectedNodeCount.value !== 1) return null
const node = canvasStore.selectedItems.find(isLGraphNode)
if (!node) return null
return (
resolveNodeDisplayName(node, {
emptyLabel: '',
untitledLabel: '',
st
}) || null
)
})
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionErrorStore.allErrorExecutionIds) {
@@ -581,38 +595,50 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
/**
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
* `includeGroup` narrows which swap/pack groups are counted (used to scope
* emphasis to the canvas selection); groups reduced to zero are omitted.
*/
function buildMissingNodeGroups(
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []
const swapCount = swapNodeGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
const packCount = missingPackGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
if (swapNodeGroups.value.length > 0) {
if (swapCount > 0) {
groups.push({
type: 'swap_nodes' as const,
groupKey: 'swap_nodes',
count: swapNodeGroups.value.length,
count: swapCount,
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
count: swapNodeGroups.value.length,
nodeTypes: error.nodeTypes,
count: swapCount,
isCloud
})
})
}
if (missingPackGroups.value.length > 0) {
if (packCount > 0) {
groups.push({
type: 'missing_node' as const,
groupKey: 'missing_node',
count: missingPackGroups.value.length,
count: packCount,
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: error.nodeTypes,
count: missingPackGroups.value.length,
count: packCount,
isCloud
})
})
@@ -699,31 +725,33 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
/** Model groups narrowed to the selection, for emphasis derivation only. */
const missingModelGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
const matched = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
if (!matched.length) return []
return groupMissingModelCandidates(matched, isCloud)
})
const filteredMissingMediaGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
/** Media groups narrowed to the selection, for emphasis derivation only. */
const missingMediaGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
const matched = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)
if (!matched.length) return []
return groupCandidatesByMediaType(matched)
})
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = countMissingModels(filteredMissingModelGroups.value)
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
if (!missingModelGroupsForSelection.value.length) return []
const count = countMissingModels(missingModelGroupsForSelection.value)
return [
{
type: 'missing_model' as const,
@@ -732,7 +760,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: filteredMissingModelGroups.value,
groups: missingModelGroupsForSelection.value,
count,
isCloud
})
@@ -740,10 +768,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
if (!missingMediaGroupsForSelection.value.length) return []
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
missingMediaGroupsForSelection.value
)
return [
{
@@ -753,7 +781,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
groups: missingMediaGroupsForSelection.value,
count: totalRows,
isCloud
})
@@ -776,47 +804,113 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
/**
* The subset of error groups whose errors belong to the current canvas
* selection. Empty when nothing is selected. Display always shows all
* groups; this subset only drives selection emphasis (auto-expand, card
* highlight, context strip).
*/
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
if (!hasSelection.value) return []
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, true)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
// pack-level problems relevant regardless of which node is selected.
return [
...buildMissingNodeGroups(),
...(filterByNode
? buildMissingModelGroupsFiltered()
: buildMissingModelGroups()),
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...buildMissingNodeGroups((nodeTypes) =>
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
),
...buildMissingModelGroupsForSelection(),
...buildMissingMediaGroupsForSelection(),
...toSortedGroups(groupsMap)
]
})
/**
* Execution node ids referenced by any missing-asset candidate (models,
* media, missing node types).
*/
const assetNodeIdsWithError = computed<string[]>(() => {
const candidateIds = [
...(missingModelStore.missingModelCandidates ?? []),
...(missingMediaStore.missingMediaCandidates ?? [])
].map((candidate) => candidate.nodeId)
const missingNodeTypeIds = (
missingNodesStore.missingNodesError?.nodeTypes ?? []
).map((nodeType) =>
typeof nodeType === 'string' ? undefined : nodeType.nodeId
)
return [...candidateIds, ...missingNodeTypeIds]
.filter((nodeId) => nodeId != null)
.map(String)
})
/**
* Asset node ids that belong to the current selection. Drives row-level
* highlighting inside the missing-* cards.
*/
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
)
})
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
})
const selectionMatchedCardIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
selectionScopedGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.id)
)
})
const selectionErrorCount = computed(() => {
if (!hasSelection.value) return 0
return selectionScopedGroups.value.reduce(
(sum, group) => sum + group.count,
0
)
})
/** Distinct nodes affected by any error (workflow-level summary). */
const errorNodeCount = computed(() => {
const executionNodeIds = allErrorGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.nodeId)
.filter((nodeId) => nodeId != null)
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
return searchErrorGroups(tabErrorGroups.value, query)
return searchErrorGroups(allErrorGroups.value, query)
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups,
filteredMissingMediaGroups,
swapNodeGroups
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
}
}

View File

@@ -0,0 +1,20 @@
import { computed } from 'vue'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
/**
* Whether UI chrome (sidebars, top menu, tabs, bottom panel) is hidden,
* leaving only the canvas and minimap. True in focus mode and in the
* error-resolution view.
*/
export function useChromeVisibility() {
const workspaceStore = useWorkspaceStore()
const errorResolutionStore = useErrorResolutionStore()
const isChromeHidden = computed(
() => workspaceStore.focusMode || errorResolutionStore.isActive
)
return { isChromeHidden }
}

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
@@ -36,6 +37,30 @@ vi.mock('@/scripts/app', () => ({
app: appMock
}))
const settingsMock = vi.hoisted(() => {
const values = new Map<string, unknown>()
return {
values,
get: vi.fn((key: string) => values.get(key)),
set: vi.fn((key: string, value: unknown) => {
values.set(key, value)
return Promise.resolve()
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingsMock
}))
const executeCommandMock = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined)
)
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: executeCommandMock })
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
@@ -60,15 +85,18 @@ function createSelectedCanvas() {
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
settingsMock.values.clear()
executeCommandMock.mockResolvedValue(undefined)
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
it('enters the error-resolution view when coming from app mode', async () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
@@ -78,15 +106,51 @@ describe('useViewErrorsInGraph', () => {
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
settingsMock.values.set('Comfy.Minimap.Visible', true)
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(errorResolutionStore.isActive).toBe(true)
expect(rightSidePanelStore.isOpen).toBeFalsy()
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(
settingsMock.set,
'entering the view must not mutate persisted settings'
).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(
executeCommandMock,
'the graph is fit into view on entry'
).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
})
it('opens the errors panel when already in graph mode', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'graph'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(errorResolutionStore.isActive).toBe(false)
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('opens graph errors when the canvas is not initialized', () => {

View File

@@ -1,20 +1,62 @@
import { nextTick } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { isAppMode } = useAppMode()
/**
* Wait until the canvas backing store reflects the now-visible container.
* While app mode is shown, the canvas container is display:none and the
* ResizeObserver in app.ts zeroes the canvas size; fitting before it
* re-measures would compute a broken (zero/NaN) scale.
*/
async function waitForCanvasResize() {
const canvasElement = canvasStore.canvas?.canvas
if (!canvasElement) return false
const maxFrames = 30
for (let frame = 0; frame < maxFrames; frame++) {
if (canvasElement.width > 0 && canvasElement.height > 0) return true
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
}
return canvasElement.width > 0 && canvasElement.height > 0
}
/** Fit the whole graph into the now-visible canvas. */
async function prepareErrorResolutionCanvas() {
await nextTick()
if (!(await waitForCanvasResize())) return
// The resize wait spans frames; the user may have already left the view
if (!errorResolutionStore.isActive) return
await commandStore.execute('Comfy.Canvas.FitView')
}
function viewErrorsInGraph() {
const fromAppMode = isAppMode.value
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
if (fromAppMode) {
errorResolutionStore.enter()
void prepareErrorResolutionCanvas()
} else {
rightSidePanelStore.openPanel('errors')
}
executionErrorStore.dismissErrorOverlay()
}

View File

@@ -2005,6 +2005,14 @@
"coreNodesFromVersion": "Core nodes from version {version}:",
"unknownVersion": "unknown"
},
"errorResolution": {
"title": "Fix workflow errors",
"backToApp": "Back to App Mode",
"allResolved": "All errors resolved",
"allResolvedDesc": "You're ready to go back to App Mode and run this workflow.",
"showErrors": "Show errors",
"hideErrors": "Hide errors"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
@@ -3715,7 +3723,7 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"fixErrors": "Fix errors",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
@@ -3768,7 +3776,6 @@
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",
@@ -3883,6 +3890,11 @@
"errors": "Errors",
"noErrors": "No errors",
"errorsDetected": "Error detected | Errors detected",
"selectedNodeErrors": "{node} — {count} error | {node} — {count} errors",
"selectedNodesErrors": "{nodes} nodes selected — {count} error | {nodes} nodes selected — {count} errors",
"errorNodeSummary": "{nodes} node — {count} error | {nodes} node — {count} errors",
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
"errorsSummary": "{count} error | {count} errors",
"resolveBeforeRun": "Resolve before running the workflow",
"expand": "Expand",
"collapse": "Collapse",

View File

@@ -20,7 +20,7 @@
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-2xl/none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>

View File

@@ -9,9 +9,22 @@
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
:aria-current="
highlightedNodeIds?.has(item.nodeId) ? 'true' : undefined
"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<!-- Emphasis lives on an inner element: the li is a TransitionGroup
child, and the emphasis transition-property would override the
list-scale move/enter/leave transitions. -->
<div
:class="
cn(
'flex min-w-0 items-center gap-2',
selectionEmphasisClass(highlightedNodeIds?.has(item.nodeId))
)
"
>
<span class="flex min-w-0 flex-1">
<button
type="button"
@@ -44,8 +57,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
@@ -56,6 +71,8 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -3,7 +3,7 @@
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden"
class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5"
>
<MissingModelRow
v-for="row in importableModelRows"
@@ -12,6 +12,7 @@
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
:highlighted="isRowHighlighted(row)"
@locate-model="emit('locateModel', $event)"
/>
</div>
@@ -36,6 +37,7 @@
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
:highlighted="isRowHighlighted(row)"
@locate-model="emit('locateModel', $event)"
/>
</div>
@@ -86,8 +88,10 @@ const MODEL_TYPE_SORT_ORDER = [
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
const { missingModelGroups, highlightedNodeIds } = defineProps<{
missingModelGroups: MissingModelGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{
@@ -172,4 +176,11 @@ function getModelTypeSortIndex(directory: string | null) {
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
}
function isRowHighlighted(row: MissingModelRowEntry) {
if (!highlightedNodeIds?.size) return false
return row.model.referencingNodes.some((ref) =>
highlightedNodeIds.has(String(ref.nodeId))
)
}
</script>

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleReferences"
data-testid="missing-model-expand"
@@ -191,6 +199,8 @@ import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
@@ -217,12 +227,15 @@ const {
model,
directory,
isAssetSupported,
canCloudImport = true
canCloudImport = true,
highlighted
} = defineProps<{
model: MissingModelViewModel
directory: string | null
isAssetSupported: boolean
canCloudImport?: boolean
/** Emphasize the header row (model referenced by the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,14 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<Button
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-expand"
@@ -153,14 +161,18 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group } = defineProps<{
const { group, highlighted } = defineProps<{
group: SwapNodeGroup
/** Emphasize the header row (group containing the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -4,6 +4,9 @@
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:highlighted="
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
@@ -11,11 +14,14 @@
</template>
<script setup lang="ts">
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { swapNodeGroups } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -39,9 +39,7 @@ const i18n = createI18n({
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
fixErrors: 'Fix errors',
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
@@ -139,7 +137,7 @@ describe('LinearControls', () => {
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
within(warning).getByRole('button', { name: 'Fix errors' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
@@ -158,7 +156,7 @@ describe('LinearControls', () => {
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(description).not.toHaveTextContent('Fix errors')
})
it.for([
@@ -171,7 +169,7 @@ describe('LinearControls', () => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
screen.queryByRole('button', { name: 'Fix errors' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'

View File

@@ -31,9 +31,7 @@ const i18n = createI18n({
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
fixErrors: 'Fix errors'
}
}
}
@@ -76,16 +74,14 @@ describe('LinearRunErrorWarning', () => {
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(description).not.toHaveTextContent('Fix errors')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
await user.click(screen.getByRole('button', { name: 'Fix errors' }))
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})

View File

@@ -56,7 +56,7 @@ const { overlayMessage, overlayTitle } = useErrorOverlayState()
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
{{ t('linearMode.fixErrors') }}
</Button>
</div>
</div>

View File

@@ -5,9 +5,9 @@ import { useI18n } from 'vue-i18n'
import Dialogue from '@/components/common/Dialogue.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { resolveRunErrorMessage } from '@/platform/errorCatalog/errorMessageResolver'
import { buildSupportUrl } from '@/platform/support/config'
import { useAppModeStore } from '@/stores/appModeStore'
@@ -17,7 +17,7 @@ defineEmits<{ navigateControls: [] }>()
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const executionErrorStore = useExecutionErrorStore()
const { buildDocsUrl, staticUrls } = useExternalLink()
const { allErrorGroups } = useErrorGroups('')
@@ -177,8 +177,8 @@ function copy(obj: unknown) {
>
{{ t('g.dismiss') }}
</Button>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.viewGraph') }}
<Button variant="textonly" size="lg" @click="viewErrorsInGraph()">
{{ t('linearMode.fixErrors') }}
</Button>
<Button
v-if="accessibleErrors.length"

View File

@@ -0,0 +1,74 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
const activeWorkflow = ref<{ key: string; activeMode?: string } | null>(null)
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return activeWorkflow.value
}
})
}))
describe('errorResolutionStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
activeWorkflow.value = { key: 'workflow-a' }
})
it('is inactive by default and toggles via enter/exit', () => {
const store = useErrorResolutionStore()
expect(store.isActive).toBe(false)
store.enter()
expect(store.isActive).toBe(true)
store.exit()
expect(store.isActive).toBe(false)
})
it('exits automatically when the active workflow changes', async () => {
const store = useErrorResolutionStore()
store.enter()
activeWorkflow.value = { key: 'workflow-b' }
await nextTick()
expect(store.isActive).toBe(false)
})
it('exits automatically when the active workflow is closed', async () => {
const store = useErrorResolutionStore()
store.enter()
activeWorkflow.value = null
await nextTick()
expect(store.isActive).toBe(false)
})
it('stays active while the workflow key is unchanged', async () => {
const store = useErrorResolutionStore()
store.enter()
activeWorkflow.value = { key: 'workflow-a' }
await nextTick()
expect(store.isActive).toBe(true)
})
it('exits when the workflow switches back to app mode', async () => {
activeWorkflow.value = { key: 'workflow-a', activeMode: 'graph' }
const store = useErrorResolutionStore()
store.enter()
activeWorkflow.value = { key: 'workflow-a', activeMode: 'app' }
await nextTick()
expect(store.isActive).toBe(false)
})
})

View File

@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
/**
* Store for the focused error-resolution view entered from App Mode.
* While active, UI chrome is hidden (like focus mode) and a floating
* error panel with a "Back to App Mode" affordance is shown.
*/
export const useErrorResolutionStore = defineStore('errorResolution', () => {
const workflowStore = useWorkflowStore()
const isActive = ref(false)
function enter() {
isActive.value = true
}
function exit() {
isActive.value = false
}
// The view is global state while the underlying mode (workflow.activeMode)
// is per-workflow, so leaving the workflow must end the view.
watch(
() => workflowStore.activeWorkflow?.key,
() => {
if (isActive.value) exit()
}
)
// Returning to app mode by any path (including the Toggle App Mode
// command while chrome is hidden) must also end the view.
watch(
() => isAppModeValue(getWorkflowMode(workflowStore.activeWorkflow)),
(inAppMode) => {
if (inAppMode && isActive.value) exit()
}
)
return { isActive, enter, exit }
})

View File

@@ -190,6 +190,10 @@ vi.mock(
vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
vi.mock('@/components/MenuHamburger.vue', () => stubModule)
vi.mock(
'@/components/errorResolution/ErrorResolutionOverlay.vue',
() => stubModule
)
vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)
describe('GraphView - reconnect wiring', () => {

View File

@@ -29,6 +29,7 @@
<DesktopCloudNotificationController />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
<ErrorResolutionOverlay />
</template>
<script setup lang="ts">
@@ -47,6 +48,7 @@ import {
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import ErrorResolutionOverlay from '@/components/errorResolution/ErrorResolutionOverlay.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'