feat(minimap): add node execution status visualization (#9187)

## Summary

Added visual indicators (colored borders) to the MiniMap to display the
real-time execution status (running, executed, or error) of nodes.

## Changes

- **What**: Added visual feedback to the MiniMap to show node execution
states (green for running/executed, red for errors) by integrating with
`useExecutionStore` and updating the canvas renderer.

## Review Focus

Confirmed that relying on the array `.includes()` check for
`executingNodeIds` in the data sources avoids unnecessary `Set`
allocations during frequent redraws.

## Screenshots 

<img width="540" height="446" alt="14949d48035db5c64cceb11f7f7f94a3"
src="https://github.com/user-attachments/assets/cac53a80-9882-43fd-a725-7003fe3fd21a"
/>

<img width="562" height="464" alt="7e922f54dea2cea4e6b66202d2ad0dd3"
src="https://github.com/user-attachments/assets/e178b981-3af0-417f-8e21-a706f192fabf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9187-feat-minimap-add-node-execution-status-visualization-3126d73d3650816eb7b3ca415cf6a8f1)
by [Unito](https://www.unito.io)
This commit is contained in:
Kelly Yang
2026-03-04 16:43:12 -08:00
committed by GitHub
parent fd9e774a29
commit 120524faa1
12 changed files with 129 additions and 24 deletions

View File

@@ -200,6 +200,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { api } from '@/scripts/api'

View File

@@ -5,6 +5,7 @@ import type { ShallowRef } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { MinimapCanvas, MinimapSettingsKey } from '../types'
@@ -201,6 +202,18 @@ export function useMinimap({
}
})
const executionStore = useExecutionStore()
watch(
() => executionStore.nodeProgressStates,
() => {
if (visible.value) {
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
}
},
{ deep: true }
)
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)

View File

@@ -17,6 +17,12 @@ vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),

View File

@@ -20,7 +20,9 @@ describe('useMinimapRenderer', () => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn()
clearRect: vi.fn(),
save: vi.fn(),
restore: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockCanvas = {

View File

@@ -13,6 +13,15 @@ import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/us
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@vueuse/core')
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),

View File

@@ -1,8 +1,11 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
let executionStore: ReturnType<typeof useExecutionStore> | null = null
/**
* Layout Store data source implementation
*/
@@ -11,12 +14,19 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
const allNodes = layoutStore.getAllNodes().value
if (allNodes.size === 0) return []
if (!executionStore) {
executionStore = useExecutionStore()
}
const nodeProgressStates = executionStore.nodeLocationProgressStates
const nodes: MinimapNodeData[] = []
for (const [nodeId, layout] of allNodes) {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
const executionState = nodeProgressStates[nodeId]?.state ?? null
nodes.push({
id: nodeId,
x: layout.position.x,
@@ -25,7 +35,8 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
height: layout.size.height,
bgcolor: graphNode?.bgcolor,
mode: graphNode?.mode,
hasErrors: graphNode?.has_errors
hasErrors: graphNode?.has_errors,
executionState
})
}

View File

@@ -1,3 +1,5 @@
import { useExecutionStore } from '@/stores/executionStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
@@ -8,16 +10,25 @@ export class LiteGraphDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
if (!this.graph?._nodes) return []
return this.graph._nodes.map((node) => ({
id: String(node.id),
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors
}))
const executionStore = useExecutionStore()
const nodeProgressStates = executionStore.nodeProgressStates
return this.graph._nodes.map((node) => {
const nodeId = String(node.id)
const executionState = nodeProgressStates[nodeId]?.state ?? null
return {
id: nodeId,
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors,
executionState
}
})
}
getNodeCount(): number {

View File

@@ -15,6 +15,13 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
// Mock useExecutionStore
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
// Helper to create mock links that satisfy LGraph['links'] type
function createMockLinks(): LGraph['links'] {
const map = new Map<number, LLink>()

View File

@@ -22,6 +22,12 @@ vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_adjusted')
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
describe('minimapCanvasRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
@@ -42,7 +48,9 @@ describe('minimapCanvasRenderer', () => {
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1
lineWidth: 1,
save: vi.fn(),
restore: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockCanvas = {

View File

@@ -26,6 +26,8 @@ function getMinimapColors() {
groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB',
bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B',
errorColor: '#FF0000',
runningColor: '#00FF00',
successColor: '#239B23',
isLightTheme
}
}
@@ -103,10 +105,19 @@ function renderNodes(
const nodes = dataSource.getNodes()
if (nodes.length === 0) return
ctx.save()
// Group nodes by color for batch rendering (performance optimization)
const nodesByColor = new Map<
string,
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
Array<{
x: number
y: number
w: number
h: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
}>
>()
for (const node of nodes) {
@@ -121,7 +132,14 @@ function renderNodes(
nodesByColor.set(color, [])
}
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
nodesByColor.get(color)!.push({
x,
y,
w,
h,
hasErrors: node.hasErrors,
executionState: node.executionState
})
}
// Batch render nodes by color
@@ -132,18 +150,29 @@ function renderNodes(
}
}
// Render error outlines if needed
if (context.settings.renderError) {
ctx.strokeStyle = colors.errorColor
ctx.lineWidth = 0.3
for (const nodes of nodesByColor.values()) {
for (const node of nodes) {
if (node.hasErrors) {
ctx.strokeRect(node.x, node.y, node.w, node.h)
}
ctx.lineWidth = 0.3
for (const nodes of nodesByColor.values()) {
for (const node of nodes) {
if (node.hasErrors && context.settings.renderError) {
ctx.strokeStyle = colors.errorColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (node.executionState === 'running') {
ctx.strokeStyle = colors.runningColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (node.executionState === 'finished') {
ctx.strokeStyle = colors.successColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (
node.executionState === 'error' &&
context.settings.renderError
) {
ctx.strokeStyle = colors.errorColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
}
}
}
ctx.restore()
}
/**

View File

@@ -80,6 +80,7 @@ export interface MinimapNodeData {
bgcolor?: string
mode?: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
}
/**

View File

@@ -276,6 +276,8 @@ export function createMockCanvas2DContext(
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
save: vi.fn(),
restore: vi.fn(),
...overrides
}
return partial as CanvasRenderingContext2D