Compare commits

...

13 Commits

Author SHA1 Message Date
GitHub Action
7bebe7f6ba [automated] Apply ESLint and Oxfmt fixes 2026-07-09 07:04:49 +00:00
jaeone94
68b4c4e123 test: cover resolution view branches flagged by codecov
- Add ErrorResolutionOverlay component tests (visibility gating and
  the back-to-app-mode flow)
- Cover the panel's narrow top bar: error count, expand toggle, and
  the narrow back button
- Cover carousel dot navigation with an exact scroll offset
- Cover the FitView skip paths: a canvas that never re-measures and
  exiting the view during the resize wait
2026-07-09 15:42:43 +09:00
jaeone94
73fa9153a5 fix: address review findings on the resolution view
- Stop collapsing the minimap on entry: it silently persisted the
  user's setting; the panel already dodges an open minimap, and a unit
  test now guards that entry mutates no persisted settings
- Exit the view whenever the workflow returns to app mode, so the
  Toggle App Mode command cannot strand an active view that re-opens
  on the next graph entry
- Snap the carousel using the track's clientWidth: the ResizeObserver
  ref is still 0 on the immediate-watch mount path, which landed the
  snap on slide 0; the unit test now asserts the exact scroll offset
- Announce the resolved state via a persistent sr-only live region;
  a region inserted together with its content is not announced, and
  the back button no longer sits inside it
- Skip FitView when the view was exited during the canvas resize wait
2026-07-09 15:42:43 +09:00
jaeone94
ab7fd49b90 test: update linear mode tests for the Fix errors label 2026-07-09 15:42:43 +09:00
jaeone94
70f49fc109 fix: restyle the resolved state to match the error card look
Center the all-resolved message in a bordered card on the panel
surface, matching the error list container, and tone the back button
down to the secondary variant.
2026-07-09 15:42:43 +09:00
jaeone94
cce933df60 feat: relabel error view entry buttons to Fix errors
Replace linearMode.viewGraph and linearMode.error.goto with a shared
linearMode.fixErrors key so the app mode banner, mobile error screen,
and error overlay all describe the resolution view they now open.
Dropping the old keys lets other locales fall back to English instead
of showing translations of the outdated labels.
2026-07-09 15:41:39 +09:00
jaeone94
e0b2de66dd feat: polish the resolution panel chrome and layout
Slim the desktop panel to the error list (no duplicate header or
search), give the narrow top bar a hero-style header, and center the
desktop panel in the band above the canvas menu, dodging the minimap
when it is open.
2026-07-09 15:41:39 +09:00
jaeone94
4c9c11a26e feat: add a carousel layout to ErrorGroupList for the resolution panel
Narrow layouts render error groups as horizontally snapping slides
with a dot indicator. Slides are always expanded (a collapsed slide
reads as an empty card) and selection emphasis snaps to the first
matched slide instead of collapsing the rest.
2026-07-09 15:41:39 +09:00
jaeone94
e97e18be40 test: cover error resolution view e2e 2026-07-09 15:41:39 +09:00
jaeone94
921232511a feat: route app mode errors to resolution view
Entering from app mode collapses the minimap and fits the graph once
the canvas has been re-measured; fitting earlier captures a zero-sized
canvas and corrupts the view transform.
2026-07-09 15:41:39 +09:00
jaeone94
ef5bf5ab0a feat: add error resolution overlay and panel
Floating error panel (right card on desktop, collapsible top bar with a
card carousel on narrow screens), resolved state, and a persistent Back
to App Mode button. Desktop placement avoids the minimap and canvas
menu.
2026-07-09 15:41:39 +09:00
jaeone94
28480387d1 feat: hide UI chrome during error resolution
Generalizes focus mode chrome hiding via useChromeVisibility and fixes
the splitter gutter preceding a hidden panel remaining hit-testable at
the screen edge (also affected focus mode).
2026-07-09 15:41:39 +09:00
jaeone94
33f9319d24 feat: add error resolution store 2026-07-09 15:40:49 +09:00
29 changed files with 1446 additions and 286 deletions

View File

@@ -167,6 +167,10 @@ export const TestIds = {
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
errorResolution: {
panel: 'error-resolution-panel',
back: 'error-resolution-back'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',

View File

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

View File

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

View File

@@ -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 focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
@@ -131,6 +131,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useChromeVisibility } from '@/composables/useChromeVisibility'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
@@ -141,9 +142,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
@@ -156,7 +155,7 @@ const unifiedWidth = computed(() =>
settingStore.get('Comfy.Sidebar.UnifiedWidth')
)
const { focusMode } = storeToRefs(workspaceStore)
const { isChromeHidden } = useChromeVisibility()
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
@@ -189,7 +188,9 @@ const lastPanelVisible = computed(
*/
const bothSidePanelsVisible = computed(
() =>
!focusMode.value && sidebarPanelVisible.value && showOffsideSplitter.value
!isChromeHidden.value &&
sidebarPanelVisible.value &&
showOffsideSplitter.value
)
const centerPanelDefaultSize = computed(() =>
@@ -264,7 +265,7 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (isChromeHidden.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -272,7 +273,7 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (isChromeHidden.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -296,7 +297,9 @@ const lastPanelStyle = computed(() => {
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
.p-splitter-gutter:has(
+ [data-pc-name='splitterpanel'][style*='display: none']
)
) {
display: none;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { fromAny } from '@total-typescript/shoehorn'
import ErrorResolutionOverlay from './ErrorResolutionOverlay.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
// The real store initializes the auth chain, which cannot run in tests
const workspaceMock = vi.hoisted(() => ({ focusMode: false }))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => workspaceMock
}))
vi.mock('@/components/errorResolution/ErrorResolutionPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
// Without this the async loader mounts the module namespace itself
// instead of unwrapping the default export
__esModule: true,
default: defineComponent({
render: () => h('section', { 'data-testid': 'error-resolution-panel' })
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorResolution: {
backToApp: 'Back to App Mode'
}
}
}
})
function renderOverlay() {
const user = userEvent.setup()
const result = render(ErrorResolutionOverlay, {
global: {
plugins: [
i18n,
createTestingPinia({ createSpy: vi.fn, stubActions: false })
]
}
})
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = fromAny({ activeMode: 'graph' })
return { user, workflowStore, ...result }
}
describe('ErrorResolutionOverlay.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
workspaceMock.focusMode = false
})
it('renders nothing while the view is inactive', () => {
renderOverlay()
expect(
screen.queryByTestId('error-resolution-panel')
).not.toBeInTheDocument()
})
it('shows the back button and panel while active in graph mode', async () => {
renderOverlay()
useErrorResolutionStore().enter()
expect(
await screen.findByTestId('error-resolution-panel')
).toBeInTheDocument()
expect(screen.getByTestId('error-resolution-back')).toBeInTheDocument()
})
it('stays hidden while the workflow is in app mode', async () => {
const { workflowStore } = renderOverlay()
useErrorResolutionStore().enter()
workflowStore.activeWorkflow = fromAny({ activeMode: 'app' })
await waitFor(() => {
expect(
screen.queryByTestId('error-resolution-panel')
).not.toBeInTheDocument()
})
})
it('returns to app mode via the back button', async () => {
const { user } = renderOverlay()
const errorResolutionStore = useErrorResolutionStore()
workspaceMock.focusMode = true
errorResolutionStore.enter()
await user.click(await screen.findByTestId('error-resolution-back'))
expect(errorResolutionStore.isActive).toBe(false)
expect(workspaceMock.focusMode).toBe(false)
expect(useCanvasStore().linearMode).toBe(true)
})
})

View File

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

View File

@@ -0,0 +1,158 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } 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 { setViewport } from '@/components/searchbox/v2/__test__/testUtils'
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()
})
it('shows a collapsible top bar with the error count on narrow viewports', async () => {
setViewport({ width: 375, height: 800 })
const { user, emitted } = renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
expect(
screen.getByText('Error detected'),
'the top bar shows the error count summary'
).toBeInTheDocument()
expect(
screen.queryByTestId('errors-summary-hero'),
'the hero is replaced by the top bar on narrow viewports'
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Hide errors' }))
await waitFor(() => {
expect(
screen.queryByTestId('selection-context-strip')
).not.toBeInTheDocument()
})
expect(
screen.getByRole('button', { name: 'Show errors' })
).toBeInTheDocument()
await user.click(screen.getByTestId('error-resolution-back'))
expect(emitted('back')).toHaveLength(1)
setViewport({ width: 1024, height: 768 })
})
})

View File

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

View File

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

View File

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

View File

@@ -89,9 +89,10 @@ function seedTwoErrorGroups(pinia: TestingPinia) {
})
}
function renderList(pinia: TestingPinia) {
function renderList(pinia: TestingPinia, props: Record<string, unknown> = {}) {
const user = userEvent.setup()
render(ErrorGroupList, {
const { rerender } = render(ErrorGroupList, {
props,
global: {
plugins: [PrimeVue, testI18n, pinia],
stubs: {
@@ -101,7 +102,7 @@ function renderList(pinia: TestingPinia) {
}
}
})
return { user }
return { user, rerender }
}
function createPinia() {
@@ -236,4 +237,68 @@ describe('ErrorGroupList selection emphasis', () => {
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()
const dots = within(screen.getByTestId('error-carousel-dots')).getAllByRole(
'button'
)
expect(dots).toHaveLength(2)
scrollTo.mockClear()
await user.click(dots[0])
expect(scrollTo, 'dot buttons scroll to their slide').toHaveBeenCalledWith(
expect.objectContaining({ left: 0 })
)
scrollTo.mockRestore()
})
})

View File

@@ -2,6 +2,7 @@
<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" />
@@ -26,10 +27,16 @@
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
: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"
>
@@ -56,7 +63,14 @@
<div
data-testid="selection-context-strip"
role="status"
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
: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"
@@ -78,246 +92,308 @@
</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)"
<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')"
>
<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"
<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)"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
<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"
>
{{ group.displayMessage }}
</p>
</div>
<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"
/>
<!-- 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"
/>
<!-- 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"
<!-- 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(item.cardId) ? 'true' : undefined
isCardInSelection(card.id) ? 'true' : undefined
"
:class="
cn(
'min-w-0',
selectionEmphasisClass(isCardInSelection(item.cardId))
selectionEmphasisClass(isCardInSelection(card.id)),
isCardInSelection(card.id) && '-my-1 py-1'
)
"
>
<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"
@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"
/>
</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>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
<!-- 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'
)
"
/>
</ErrorCardSection>
</TransitionGroup>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
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'
@@ -357,6 +433,12 @@ interface ExecutionItemListEntry {
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()
@@ -484,6 +566,35 @@ const strip = computed(() => {
}
})
// 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)
}
@@ -498,23 +609,32 @@ const selectionEmphasisSignature = computed(() =>
: ''
)
/**
* Selection acts as emphasis, not a filter: expand the groups containing
* the selected nodes' errors and collapse the rest. When the emphasis ends
* (selection cleared or moved to a node without errors), re-expand all
* groups so the tab reads as the workflow overview again.
*/
// 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))
}

View File

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

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorResolutionStore } from '@/stores/workspace/errorResolutionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
@@ -36,11 +37,35 @@ vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
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({ width = 800, height = 600 } = {}) {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.width = width
canvasElement.height = height
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
@@ -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,84 @@ 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('skips FitView when the canvas never re-measures', async () => {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const { canvas } = createSelectedCanvas({ width: 0, height: 0 })
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
useViewErrorsInGraph().viewErrorsInGraph()
// Outlast the 30-frame resize wait
await new Promise((resolve) => setTimeout(resolve, 700))
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('skips FitView when the view is exited during the resize wait', async () => {
const canvasStore = useCanvasStore()
const errorResolutionStore = useErrorResolutionStore()
const workflowStore = useWorkflowStore()
const { canvas } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
useViewErrorsInGraph().viewErrorsInGraph()
errorResolutionStore.exit()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('opens the errors panel when already in graph mode', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const errorResolutionStore = useErrorResolutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'graph'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(errorResolutionStore.isActive).toBe(false)
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(executeCommandMock).not.toHaveBeenCalled()
})
it('opens graph errors when the canvas is not initialized', () => {

View File

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

View File

@@ -2004,6 +2004,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",
@@ -3714,7 +3722,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",
@@ -3767,7 +3775,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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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