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

@@ -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
}
}