Files
ComfyUI_frontend/src/components/rightSidePanel/errors/ErrorNodeCard.vue
jaeone94 1efe8d9da5 feat: make errors tab selection an emphasis instead of a filter (#13459)
## Summary

Selecting a node no longer filters the errors tab down to that node —
the tab now always shows every error in the workflow, and selection
instead *emphasizes* the matching entries (auto-expand, row highlight,
and a context label), so the error count never lies about whether the
workflow can run.

## Why

The errors tab has quietly been playing two roles at once. It is a
**status surface** ("is this workflow runnable? what's broken and how
much?") — that's what the hero count, the panel-button badge, and the
Run warning all lean on. But it also behaves like an **inspector**:
select a node and the whole list silently narrows to that node's errors.

Mixing the two is confusing in a very concrete way: with 3 errors in the
workflow, clicking one error node makes the tab read "1 Error detected".
Fix that one error while it's selected and the tab reads as clean —
while Run would still fail on the other two. The count changes meaning
depending on an invisible condition (selection), and it disagrees with
the global badges right next to it. This is the same reason VS Code's
Problems panel always lists everything and makes "current file only" an
explicit toggle rather than an implicit one.

## Changes

- **What**: Selection now works as emphasis on top of an always-complete
list:
- The hero count and the group list always describe the whole workflow,
regardless of selection.
- Selecting a node with errors auto-expands the groups containing them
and collapses the rest; clearing the selection (or moving it to an
error-free node) restores the expansion. Manual collapse choices are
left alone when a selection never matched anything.
- Matching entries get a background highlight using the design-system
selection blue (`--color-blue-selection`), so the panel emphasis
visually matches the canvas selection color. The highlight fades in/out
and bleeds slightly past the text without shifting any layout. This
works across all error kinds: execution errors, missing models, missing
media, missing node packs, and swap suggestions (each row/pack that
references the selected node is highlighted).
- A **resident context strip** sits between the hero and the list. With
no selection it reads `{n} nodes — {count} errors` as a workflow
summary; while a selection has errors it switches to `{node title} —
{count} errors` (or `{n} nodes selected — …` for multi-select). Because
the strip always occupies its slot, selecting/deselecting never reflows
the list.
- The strip is deliberately **always visible** rather than mounted on
demand: we plan to rename the tab to "Issues" and downgrade the
missing-* categories from errors to warnings, at which point this same
line becomes the mixed status readout (`X nodes — X errors / X
warnings`). Landing it as a resident status line now means that change
is a label swap, not a layout change.
- **Refactor**: the tab body (search, hero, grouped cards,
locate/install/replace handlers) is extracted from `TabErrors.vue` into
a reusable `ErrorGroupList.vue` — a follow-up PR mounts it outside the
sidebar. The old selection-filter machinery is kept internally as
`selectionScopedGroups` and now only derives the emphasis state (matched
group keys / card ids / asset node ids, selection error count). The
orphaned `compact` prop on `ErrorNodeCard` is removed along with it.
- **Fixes along the way**: node titles containing `=`/`&` no longer
render as HTML entities in the strip (title goes through `i18n-t` slots
instead of an escaped `t()` param), untitled nodes fall back to
"Untitled" instead of producing "1 nodes selected", and emphasized rows
expose their state to assistive tech via `aria-current` while the strip
announces via `role="status"`.

## Review Focus

- `useErrorGroups.ts`: the selection-emphasis derivation
(`selectionScopedGroups` and the `selectionMatched*` computeds).
Selection matching resolves execution ids through the graph (and by
container prefix for subgraph selections) rather than comparing raw ids
— the new unit tests pin both paths.
- `ErrorGroupList.vue`: the emphasis watcher (immediate,
membership-signature based) that syncs collapse state, and the strip
mode switch. The component tests cover the expand/collapse/restore
cycle, emphasis for selections that predate mount, and the strip label
states.
- The two e2e tests that previously asserted the filtering behavior now
assert the new one (counts stay global, strip shows the selection-scoped
count, deselect returns to the summary).

Known follow-ups (intentionally out of scope): distinct-node counting
can over/under-count in subgraph + mixed-error edge cases (execution ids
vs serialized ids), snapshot/restore of user collapse state across
emphasis, and narrowing the list-container `aria-live` region.

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/ccf98954-83ed-4333-ba4e-31cedb9fc38b

### After 


https://github.com/user-attachments/assets/f7317e4b-71c2-4009-94cc-25287979c0e4



https://github.com/user-attachments/assets/9008c100-0ec0-4612-8fe4-942fec1be2fe



https://github.com/user-attachments/assets/45734552-9986-41f8-93ad-5d072e355d3d
2026-07-08 17:56:23 +00:00

252 lines
8.9 KiB
Vue

<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
>
{{ card.nodeTitle || card.title }}
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="hasRuntimeError"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="t('g.details')"
:aria-controls="runtimeDetailsControlIds || undefined"
:aria-expanded="runtimeDetailsExpanded"
@click.stop="toggleRuntimeDetails"
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<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.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
</div>
<div
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
>
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex min-h-0 flex-col gap-1"
>
<p
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ getInlineMessage(error) }}
</p>
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
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-sm/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"
>
{{ getInlineItemLabel(error) }}
</button>
<span v-else>
{{ getInlineItemLabel(error) }}
</span>
</li>
</ul>
<div
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<TransitionCollapse>
<div
v-if="error.isRuntimeError && runtimeDetailsExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-1"
>
<div
v-if="getInlineDetails(error, idx)"
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-1 py-1">
<span
class="text-xs font-semibold text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto">
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
</div>
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
<div class="flex items-center justify-between gap-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-4" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-4" />
{{ t('g.findOnGithub') }}
</Button>
</div>
</div>
</div>
</TransitionCollapse>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const { card } = defineProps<{
card: ErrorCardData
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
copyToClipboard: [text: string]
}>()
const { t } = useI18n()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
.filter(Boolean)
.join(' ')
)
function toggleRuntimeDetails() {
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
}
function handleLocateNode() {
if (card.nodeId) {
emit('locateNode', card.nodeId)
}
}
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getCopyMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
function getInlineMessage(error: ErrorItem | undefined) {
if (!error || error.displayMessage) return undefined
return error.message
}
function getInlineItemLabel(error: ErrorItem | undefined) {
if (!error || error.isRuntimeError) return undefined
return error.displayItemLabel
}
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
if (getInlineItemLabel(error)) return undefined
return displayedDetailsMap.value[idx]
}
function getRuntimeDetailsId(idx: number) {
return `${card.id}-runtime-details-${idx}`
}
</script>