Feat/errors tab panel (#8807)

## Summary

Add a dedicated **Errors tab** to the Right Side Panel that displays
prompt-level, node validation, and runtime execution errors in a
unified, searchable, grouped view — replacing the need to rely solely on
modal dialogs for error inspection.

## Changes

- **What**:
  - **New components** (`errors/` directory):
- `TabErrors.vue` — Main error tab with search, grouping by class type,
and canvas navigation (locate node / enter subgraph).
- `ErrorNodeCard.vue` — Renders a single error card with node ID badge,
title, action buttons, and error details.
- `types.ts` — Shared type definitions (`ErrorItem`, `ErrorCardData`,
`ErrorGroup`).
- **`executionStore.ts`** — Added `PromptError` interface,
`lastPromptError` ref and `hasAnyError` computed getter. Clears
`lastPromptError` alongside existing error state on execution start and
graph clear.
- **`rightSidePanelStore.ts`** — Registered `'errors'` as a valid tab
value.
- **`app.ts`** — On prompt submission failure (`PromptExecutionError`),
stores prompt-level errors (when no node errors exist) into
`lastPromptError`. On both runtime execution error and prompt error,
deselects all nodes and opens the errors tab automatically.
- **`RightSidePanel.vue`** — Shows the `'errors'` tab (with ⚠ icon) when
errors exist and no node is selected. Routes to `TabErrors` component.
- **`TopMenuSection.vue`** — Highlights the action bar with a red border
when any error exists, using `hasAnyError`.
- **`SectionWidgets.vue`** — Detects per-node errors by matching
execution IDs to graph node IDs. Shows an error icon (⚠) and "See Error"
button that navigates to the errors tab.
- **`en/main.json`** — Added i18n keys: `errors`, `noErrors`,
`enterSubgraph`, `seeError`, `promptErrors.*`, and `errorHelp*`.
- **Testing**: 6 unit tests (`TabErrors.test.ts`) covering
prompt/node/runtime errors, search filtering, and clipboard copy.
- **Storybook**: 7 stories (`ErrorNodeCard.stories.ts`) for badge
visibility, subgraph buttons, multiple errors, runtime tracebacks, and
prompt-only errors.
- **Breaking**: None
- **Dependencies**: None — uses only existing project dependencies
(`vue-i18n`, `pinia`, `primevue`)

## Related Work

> **Note**: Upstream PR #8603 (`New bottom button and badges`)
introduced a separate `TabError.vue` (singular) that shows per-node
errors when a specific node is selected. Our `TabErrors.vue` (plural)
provides the **global error overview** — a different scope. The two tabs
coexist:
> - `'error'` (singular) → appears when a node with errors is selected →
shows only that node's errors
> - `'errors'` (plural) → appears when no node is selected and errors
exist → shows all errors grouped by class type
>
> A future consolidation of these two tabs may be desirable after design
review.

## Architecture

```
executionStore
├── lastPromptError: PromptError | null     ← NEW (prompt-level errors without node IDs)
├── lastNodeErrors: Record<string, NodeError>  (existing)
├── lastExecutionError: ExecutionError         (existing)
└── hasAnyError: ComputedRef<boolean>       ← NEW (centralized error detection)

TabErrors.vue (errors tab - global view)
├── errorGroups: ComputedRef<ErrorGroup[]>  ← normalizes all 3 error sources
├── filteredGroups                          ← search-filtered view
├── locateNode()                            ← pan canvas to node
├── enterSubgraph()                         ← navigate into subgraph
└── ErrorNodeCard.vue                       ← per-node card with copy/locate actions

types.ts
├── ErrorItem      { message, details?, isRuntimeError? }
├── ErrorCardData  { id, title, nodeId?, errors[] }
└── ErrorGroup     { title, cards[], priority }
```

## Review Focus

1. **Error normalization logic** (`TabErrors.vue` L75–150): Three
different error sources (prompt, node validation, runtime) are
normalized into a common `ErrorGroup → ErrorCardData → ErrorItem`
hierarchy. Edge cases to verify:
- Prompt errors with known vs unknown types (known types use localized
descriptions)
   - Multiple errors on the same node (grouped into one card)
   - Runtime errors with long tracebacks (capped height with scroll)

2. **Canvas navigation** (`TabErrors.vue` L210–250): The `locateNode`
and `enterSubgraph` functions navigate to potentially nested subgraphs.
The double `requestAnimationFrame` is required due to LiteGraph's
asynchronous subgraph switching — worth verifying this timing is
sufficient.

3. **Store getter consolidation**: `hasAnyError` replaces duplicated
logic in `TopMenuSection` and `RightSidePanel`. Confirm that the
reactive dependency chain works correctly (it depends on 3 separate
refs).

4. **Coexistence with upstream `TabError.vue`**: The singular `'error'`
tab (upstream, PR #8603) and our plural `'errors'` tab serve different
purposes but share similar naming. Consider whether a unified approach
is preferred.

## Test Results

```
✓ renders "no errors" state when store is empty
✓ renders prompt-level errors (Group title = error message)
✓ renders node validation errors grouped by class_type
✓ renders runtime execution errors from WebSocket
✓ filters errors based on search query
✓ calls copyToClipboard when copy button is clicked

Test Files  1 passed (1)
     Tests  6 passed (6)
```

## Screenshots (if applicable)
<img width="1238" height="1914" alt="image"
src="https://github.com/user-attachments/assets/ec39b872-cca1-4076-8795-8bc7c05dc665"
/>
<img width="669" height="1028" alt="image"
src="https://github.com/user-attachments/assets/bdcaa82a-34b0-46a5-a08f-14950c5a479b"
/>
<img width="644" height="1005" alt="image"
src="https://github.com/user-attachments/assets/ffef38c6-8f42-4c01-a0de-11709d54b638"
/>
<img width="672" height="505" alt="image"
src="https://github.com/user-attachments/assets/5cff7f57-8d79-4808-a71e-9ad05bab6e17"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8807-Feat-errors-tab-panel-3046d73d36508127981ac670a70da467)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-02-18 14:01:15 +09:00
committed by GitHub
parent cde872fcf7
commit 1349fffbce
19 changed files with 1185 additions and 9 deletions

View File

@@ -36,7 +36,14 @@
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
:class="
cn(
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
@@ -168,6 +175,7 @@ import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@/utils/tailwindUtil'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -252,6 +260,8 @@ const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -33,6 +33,7 @@ import {
useFlatAndCategorizeSelectedItems
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
@@ -40,6 +41,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -102,7 +105,10 @@ const selectedNodeErrors = computed(() =>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
if (
selectedNodeErrors.value.length &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('g.error'),
value: 'error',
@@ -110,6 +116,18 @@ const tabs = computed<RightSidePanelTabList>(() => {
})
}
if (
hasAnyError.value &&
!hasSelection.value &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('rightSidePanel.errors'),
value: 'errors',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
flattedItems.value.length > 1
@@ -298,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabErrors v-if="activeTab === 'errors'" />
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
details: 'Input: text\nExpected: STRING'
}
]
}
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
details: ''
},
{
message: 'Value "NaN" is not a valid number for "strength".',
details: 'Expected: FLOAT [0.0 .. 1.0]'
}
]
}
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
details: [
'Traceback (most recent call last):',
' File "ksampler.py", line 142, in sample',
' samples = model.apply(latent)',
'RuntimeError: CUDA out of memory.'
].join('\n'),
isRuntimeError: true
}
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
errors: [
{
message:
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
}
}

View File

@@ -0,0 +1,110 @@
<template>
<div class="overflow-hidden">
<!-- Card Header (Node ID & Actions) -->
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle"
class="flex-1 text-sm text-muted-foreground truncate font-medium"
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
@click.stop="emit('locateNode', card.nodeId ?? '')"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
</div>
<!-- Multiple Errors within one Card -->
<div class="divide-y divide-interface-stroke/20 space-y-4">
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex flex-col gap-3"
>
<!-- Error Message -->
<p
v-if="error.message"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
>
{{ error.message }}
</p>
<!-- Traceback / Details -->
<div
v-if="error.details"
:class="
cn(
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
>
{{ error.details }}
</p>
</div>
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-[11px]"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
{{ t('g.copy') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const { card, showNodeIdBadge = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
const { t } = useI18n()
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',
[error.message, error.details].filter(Boolean).join('\n\n')
)
}
</script>

View File

@@ -0,0 +1,218 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
forEachNode: vi.fn()
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
}
})
})
function mountComponent(initialState = {}) {
return mount(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
FormSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
it('renders "no errors" state when store is empty', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
})
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
})
it('renders node validation errors grouped by class_type', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
})
it('renders runtime execution errors from WebSocket', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
})
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('calls copyToClipboard when copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
}
}
})
// Find the copy button (rendered inside ErrorNodeCard)
const copyButtons = wrapper.findAll('button')
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
})

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex flex-col h-full min-w-0">
<!-- Search bar -->
<div
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
>
<FormSearchInput v-model="searchQuery" />
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto min-w-0">
<div
v-if="filteredGroups.length === 0"
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div v-else>
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<i
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
</span>
<span
v-if="group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
@locate-node="focusNode"
@enter-subgraph="enterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
</PropertiesAccordionItem>
</div>
</div>
<!-- Fixed Footer: Help Links -->
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
class="m-0 text-sm text-muted-foreground leading-tight break-words"
>
<template #github>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="openGitHubIssues"
>
{{ t('rightSidePanel.errorHelpGithub') }}
</Button>
</template>
<template #support>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="contactSupport"
>
{{ t('rightSidePanel.errorHelpSupport') }}
</Button>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from './useErrorGroups'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const rightSidePanelStore = useRightSidePanelStore()
const searchQuery = ref('')
const settingStore = useSettingStore()
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
const { filteredGroups } = useErrorGroups(searchQuery, t)
const collapseState = reactive<Record<string, boolean>>({})
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
for (const group of filteredGroups.value) {
const hasMatch = group.cards.some(
(card) => card.graphNodeId === graphNodeId
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -0,0 +1,21 @@
export interface ErrorItem {
message: string
details?: string
isRuntimeError?: boolean
}
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -0,0 +1,236 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup } from './types'
import { isNodeExecutionId } from '@/types/nodeIdentification'
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
}
interface ErrorSearchItem {
groupIndex: number
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableMessage: string
searchableDetails: string
}
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
function resolveNodeInfo(nodeId: string): {
title: string
graphNodeId: string | undefined
} {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
title: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
}
}
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
}
function processPromptError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
) {
if (!executionStore.lastPromptError) return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
cards.set('__prompt__', {
id: '__prompt__',
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `node-${nodeId}`,
title: nodeError.class_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) continue
card.errors.push(
...nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
}))
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const nodeId = String(e.node_id)
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `exec-${nodeId}`,
title: e.node_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push({
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
})
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
})
}
function buildErrorGroups(
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
): ErrorGroup[] {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, executionStore, t)
processNodeErrors(groupsMap, executionStore)
processExecutionError(groupsMap, executionStore)
return toSortedGroups(groupsMap)
}
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]!
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]!
searchableList.push({
groupIndex: gi,
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query)
const matchedCardKeys = new Set(
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const errorGroups = computed<ErrorGroup[]>(() =>
buildErrorGroups(executionStore, t)
)
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(errorGroups.value, query)
})
return {
errorGroups,
filteredGroups
}
}

View File

@@ -12,6 +12,10 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -60,6 +64,8 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -104,6 +110,11 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const nodeHasError = computed(() => {
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -122,6 +133,13 @@ function handleLocateNode() {
}
}
function navigateToErrorTab() {
if (!targetNode.value) return
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
@@ -162,9 +180,20 @@ defineExpose({
:tooltip
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<span class="truncate">
<i
v-if="nodeHasError"
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span
:class="
cn(
'truncate',
nodeHasError && 'text-destructive-background-hover'
)
"
>
<slot name="label">
{{ displayLabel }}
</slot>
@@ -177,6 +206,15 @@ defineExpose({
{{ parentGroup.title }}
</span>
</span>
<Button
v-if="nodeHasError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}
</Button>
<Button
v-if="!isEmpty"
variant="textonly"

View File

@@ -0,0 +1,56 @@
import { nextTick } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return
if (canvas.graph !== targetGraph) {
canvas.subgraph = targetGraph.isRootGraph
? undefined
: (targetGraph as Subgraph)
canvas.setGraph(targetGraph)
await nextTick()
// Double RAF to wait for LiteGraph's internal canvas frame cycle
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
}
}
export function useFocusNode() {
const canvasStore = useCanvasStore()
async function focusNode(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
}
}

View File

@@ -2977,6 +2977,21 @@
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs",
"errors": "Errors",
"noErrors": "No errors",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"promptErrors": {
"prompt_no_outputs": {
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
}
},
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters"
},

View File

@@ -350,6 +350,10 @@
"name": "Batch count limit",
"tooltip": "The maximum number of tasks added to the queue at one button click"
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Show errors tab in side panel",
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
},
"Comfy_Sidebar_Location": {
"name": "Sidebar location",
"options": {

View File

@@ -1221,5 +1221,16 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
experimental: true,
versionAdded: '1.40.0'
},
{
id: 'Comfy.RightSidePanel.ShowErrorsTab',
category: ['Comfy', 'Error System'],
name: 'Show errors tab in side panel',
tooltip:
'When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance.',
type: 'boolean',
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
}
]

View File

@@ -135,7 +135,10 @@
>
<button
v-if="hasAnyError"
@click.stop="useRightSidePanelStore().openPanel('error')"
@click.stop="
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
useRightSidePanelStore().openPanel('error')
"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />

View File

@@ -89,7 +89,7 @@ function handleFocus(event: FocusEvent) {
<input
v-model="searchQuery"
type="text"
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2"
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2 min-w-0"
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
:autofocus
@focus="handleFocus"

View File

@@ -204,6 +204,12 @@ const zPromptResponse = z.object({
error: z.union([z.string(), zError])
})
const zPromptError = z.object({
type: z.string(),
message: z.string(),
details: z.string()
})
const zDeviceStats = z.object({
name: z.string(),
type: z.string(),
@@ -433,12 +439,14 @@ const zSettings = z.object({
'LiteGraph.Pointer.TrackpadGestures': z.boolean(),
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
export type PromptResponse = z.infer<typeof zPromptResponse>
export type PromptError = z.infer<typeof zPromptError>
export type NodeError = z.infer<typeof zNodeError>
export type Settings = z.infer<typeof zSettings>
export type DeviceStats = z.infer<typeof zDeviceStats>

View File

@@ -70,6 +70,7 @@ import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
@@ -714,6 +715,10 @@ export class ComfyApp {
} else {
useDialogService().showExecutionErrorDialog(detail)
}
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
this.canvas.deselectAll()
useRightSidePanelStore().openPanel('errors')
}
this.canvas.draw(true, true)
})
@@ -1398,6 +1403,8 @@ export class ComfyApp {
this.processingQueue = true
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
executionStore.lastPromptError = null
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
@@ -1468,6 +1475,37 @@ export class ComfyApp {
if (error instanceof PromptExecutionError) {
executionStore.lastNodeErrors = error.response.node_errors ?? null
// Store prompt-level error separately only when no node-specific errors exist,
// because node errors already carry the full context. Prompt-level errors
// (e.g. prompt_no_outputs, no_prompt) lack node IDs and need their own path.
const nodeErrors = error.response.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
if (!hasNodeErrors) {
const respError = error.response.error
if (respError && typeof respError === 'object') {
executionStore.lastPromptError = {
type: respError.type,
message: respError.message,
details: respError.details ?? ''
}
} else if (typeof respError === 'string') {
executionStore.lastPromptError = {
type: 'error',
message: respError,
details: ''
}
}
}
// Clear selection and open the error panel so the user can immediately
// see the error details without extra clicks.
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
this.canvas.deselectAll()
useRightSidePanelStore().openPanel('errors')
}
this.canvas.draw(true, true)
}
break
@@ -1845,6 +1883,7 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
executionStore.lastPromptError = null
useDomWidgetStore().clear()

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { isEmpty } from 'es-toolkit/compat'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -25,7 +26,8 @@ import type {
NotificationWsMessage,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage
ProgressWsMessage,
PromptError
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -113,6 +115,7 @@ export const useExecutionStore = defineStore('execution', () => {
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(null)
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
const nodeProgressStatesByPrompt = ref<
@@ -287,6 +290,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
lastExecutionError.value = null
lastPromptError.value = null
activePromptId.value = e.detail.prompt_id
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
clearInitializationByPromptId(activePromptId.value)
@@ -462,6 +466,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
activePromptId.value = null
_executingNodeProgress.value = null
lastPromptError.value = null
}
function getNodeIdIfExecuting(nodeId: string | number) {
@@ -641,6 +646,49 @@ export const useExecutionStore = defineStore('execution', () => {
}
})
/** Whether a runtime execution error is present */
const hasExecutionError = computed(() => !!lastExecutionError.value)
/** Whether a prompt-level error is present (e.g. invalid_prompt, prompt_no_outputs) */
const hasPromptError = computed(() => !!lastPromptError.value)
/** Whether any node validation errors are present */
const hasNodeError = computed(
() => !!lastNodeErrors.value && !isEmpty(lastNodeErrors.value)
)
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
const hasAnyError = computed(
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
)
/** Pre-computed Set of graph node IDs (as strings) that have errors. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
const activeGraph = useCanvasStore().currentGraph ?? app.rootGraph
if (lastNodeErrors.value) {
for (const executionId of Object.keys(lastNodeErrors.value)) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
}
}
}
if (lastExecutionError.value) {
const execNodeId = String(lastExecutionError.value.node_id)
const graphNode = getNodeByExecutionId(app.rootGraph, execNodeId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
}
}
return ids
})
return {
isIdle,
clientId,
@@ -648,6 +696,8 @@ export const useExecutionStore = defineStore('execution', () => {
queuedPrompts,
lastNodeErrors,
lastExecutionError,
lastPromptError,
hasAnyError,
lastExecutionErrorNodeId,
executingNodeId,
executingNodeIds,
@@ -679,6 +729,7 @@ export const useExecutionStore = defineStore('execution', () => {
promptIdToWorkflowId,
// Node error lookup helpers
getNodeErrors,
slotHasError
slotHasError,
activeGraphErrorNodeIds
}
})

View File

@@ -10,6 +10,7 @@ export type RightSidePanelTab =
| 'settings'
| 'info'
| 'subgraph'
| 'errors'
type RightSidePanelSection = 'advanced-inputs' | string
@@ -33,6 +34,12 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const activeTab = ref<RightSidePanelTab>('parameters')
const isEditingSubgraph = computed(() => activeTab.value === 'subgraph')
const focusedSection = ref<RightSidePanelSection | null>(null)
/**
* Graph node ID to focus in the errors tab.
* Set by SectionWidgets when the user clicks "See Error", consumed and
* cleared by TabErrors after expanding the relevant error group.
*/
const focusedErrorNodeId = ref<string | null>(null)
const searchQuery = ref('')
// Auto-close panel when switching to legacy menu mode
@@ -80,6 +87,7 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
activeTab,
isEditingSubgraph,
focusedSection,
focusedErrorNodeId,
searchQuery,
openPanel,
closePanel,