mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 15:47:53 +00:00
Compare commits
25 Commits
codex/crit
...
jaeone/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf19a0bc42 | ||
|
|
cf4b3ae50b | ||
|
|
4da3dcd6e3 | ||
|
|
448ebb6cc6 | ||
|
|
c46103687b | ||
|
|
7188ac1e31 | ||
|
|
6b62382989 | ||
|
|
5c445db96c | ||
|
|
742ba0dddd | ||
|
|
81d97858b9 | ||
|
|
5be1fc99f3 | ||
|
|
11beaea704 | ||
|
|
df0fe84343 | ||
|
|
39d7f7bb7f | ||
|
|
2db7b477b9 | ||
|
|
8264fb0603 | ||
|
|
40eac63410 | ||
|
|
ae755a2276 | ||
|
|
35dc4eff83 | ||
|
|
8e90b33fd3 | ||
|
|
95a7e43f61 | ||
|
|
237432ef26 | ||
|
|
b8d643ca82 | ||
|
|
f088382e3d | ||
|
|
49d8dc47f3 |
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
191
browser_tests/tests/errorResolution.spec.ts
Normal file
191
browser_tests/tests/errorResolution.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
? t('linearMode.error.goto')
|
||||
? t('linearMode.fixErrors')
|
||||
: t('errorOverlay.viewDetails')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
47
src/components/errorResolution/ErrorResolutionOverlay.vue
Normal file
47
src/components/errorResolution/ErrorResolutionOverlay.vue
Normal 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>
|
||||
119
src/components/errorResolution/ErrorResolutionPanel.test.ts
Normal file
119
src/components/errorResolution/ErrorResolutionPanel.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
161
src/components/errorResolution/ErrorResolutionPanel.vue
Normal file
161
src/components/errorResolution/ErrorResolutionPanel.vue
Normal 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>
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
|
||||
294
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal file
294
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
729
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal file
729
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal 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>
|
||||
@@ -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) : ''))
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal file
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal 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))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
20
src/composables/useChromeVisibility.ts
Normal file
20
src/composables/useChromeVisibility.ts
Normal 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 }
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
74
src/stores/workspace/errorResolutionStore.test.ts
Normal file
74
src/stores/workspace/errorResolutionStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
44
src/stores/workspace/errorResolutionStore.ts
Normal file
44
src/stores/workspace/errorResolutionStore.ts
Normal 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 }
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user