fix(copilot): copilot edit router block accepts semantic handles (#2857)

* Fix copilot diff controls

* Fix router block for copilot

* Fix queue

* Fix lint

* Get block options and config for subflows

* Lint
This commit is contained in:
Siddharth Ganesan
2026-01-16 15:54:28 -08:00
committed by GitHub
parent 78e4ca9d45
commit aa80116b99
9 changed files with 338 additions and 14 deletions
@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores/notifications'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
@@ -37,8 +38,15 @@ export const DiffControls = memo(function DiffControls() {
)
)
const { activeWorkflowId } = useWorkflowRegistry(
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const allNotifications = useNotificationStore((state) => state.notifications)
const hasVisibleNotifications = allNotifications.length > 0
const hasVisibleNotifications = useMemo(() => {
if (!activeWorkflowId) return false
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
@@ -94,7 +94,6 @@ export function useCheckpointManagement(
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
onEditModeChange?.(true)
logger.info('Checkpoint reverted and removed from message', {
messageId: message.id,
@@ -115,7 +114,6 @@ export function useCheckpointManagement(
messages,
currentChat,
onRevertModeChange,
onEditModeChange,
])
/**
@@ -176,6 +174,7 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
@@ -219,6 +218,7 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
@@ -166,6 +166,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
},
@@ -356,6 +356,64 @@ export const getBlockConfigServerTool: BaseServerTool<
const logger = createLogger('GetBlockConfigServerTool')
logger.debug('Executing get_block_config', { blockType, operation, trigger })
if (blockType === 'loop') {
const result = {
blockType,
blockName: 'Loop',
operation,
trigger,
inputs: {
loopType: {
type: 'string',
description: 'Loop type',
options: ['for', 'forEach', 'while', 'doWhile'],
default: 'for',
},
iterations: {
type: 'number',
description: 'Number of iterations (for loop type "for")',
},
collection: {
type: 'string',
description: 'Collection to iterate (for loop type "forEach")',
},
condition: {
type: 'string',
description: 'Loop condition (for loop types "while" and "doWhile")',
},
},
outputs: {},
}
return GetBlockConfigResult.parse(result)
}
if (blockType === 'parallel') {
const result = {
blockType,
blockName: 'Parallel',
operation,
trigger,
inputs: {
parallelType: {
type: 'string',
description: 'Parallel type',
options: ['count', 'collection'],
default: 'count',
},
count: {
type: 'number',
description: 'Number of parallel branches (for parallel type "count")',
},
collection: {
type: 'string',
description: 'Collection to branch over (for parallel type "collection")',
},
},
outputs: {},
}
return GetBlockConfigResult.parse(result)
}
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
@@ -21,6 +21,40 @@ export const getBlockOptionsServerTool: BaseServerTool<
const logger = createLogger('GetBlockOptionsServerTool')
logger.debug('Executing get_block_options', { blockId })
if (blockId === 'loop') {
const result = {
blockId,
blockName: 'Loop',
operations: [
{ id: 'for', name: 'For', description: 'Run a fixed number of iterations.' },
{ id: 'forEach', name: 'For each', description: 'Iterate over a collection.' },
{ id: 'while', name: 'While', description: 'Repeat while a condition is true.' },
{
id: 'doWhile',
name: 'Do while',
description: 'Run once, then repeat while a condition is true.',
},
],
}
return GetBlockOptionsResult.parse(result)
}
if (blockId === 'parallel') {
const result = {
blockId,
blockName: 'Parallel',
operations: [
{ id: 'count', name: 'Count', description: 'Run a fixed number of parallel branches.' },
{
id: 'collection',
name: 'Collection',
description: 'Run one branch per collection item.',
},
],
}
return GetBlockOptionsResult.parse(result)
}
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
@@ -878,6 +878,25 @@ function validateSourceHandleForBlock(
error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`,
}
case 'router_v2': {
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
}
}
const routesValue = sourceBlock?.subBlocks?.routes?.value
if (!routesValue) {
return {
valid: false,
error: `Invalid router handle "${sourceHandle}" - no routes defined`,
}
}
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
}
default:
if (sourceHandle === 'source') {
return { valid: true }
@@ -963,6 +982,85 @@ function validateConditionHandle(
}
}
/**
* Validates router handle references a valid route in the block.
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
*/
function validateRouterHandle(
sourceHandle: string,
blockId: string,
routesValue: string | any[]
): EdgeHandleValidationResult {
let routes: any[]
if (typeof routesValue === 'string') {
try {
routes = JSON.parse(routesValue)
} catch {
return {
valid: false,
error: `Cannot validate router handle "${sourceHandle}" - routes is not valid JSON`,
}
}
} else if (Array.isArray(routesValue)) {
routes = routesValue
} else {
return {
valid: false,
error: `Cannot validate router handle "${sourceHandle}" - routes is not an array`,
}
}
if (!Array.isArray(routes) || routes.length === 0) {
return {
valid: false,
error: `Invalid router handle "${sourceHandle}" - no routes defined`,
}
}
const validHandles = new Set<string>()
const semanticPrefix = `router-${blockId}-`
for (let i = 0; i < routes.length; i++) {
const route = routes[i]
// Accept internal ID format: router-{uuid}
if (route.id) {
validHandles.add(`router-${route.id}`)
}
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
validHandles.add(`${semanticPrefix}route-${i + 1}`)
// Accept normalized title format: router-{blockId}-{normalized-title}
// Normalize: lowercase, replace spaces with dashes, remove special chars
if (route.title && typeof route.title === 'string') {
const normalizedTitle = route.title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (normalizedTitle) {
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
}
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
}
}
/**
* Validates target handle is valid (must be 'target')
*/
@@ -268,12 +268,128 @@ function sanitizeSubBlocks(
return sanitized
}
/**
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
*/
function convertConditionHandleToSemantic(
handle: string,
blockId: string,
block: BlockState
): string {
if (!handle.startsWith('condition-')) {
return handle
}
// Extract the condition UUID from the handle
const conditionId = handle.substring('condition-'.length)
// Get conditions from block subBlocks
const conditionsValue = block.subBlocks?.conditions?.value
if (!conditionsValue || typeof conditionsValue !== 'string') {
return handle
}
let conditions: Array<{ id: string; title: string }>
try {
conditions = JSON.parse(conditionsValue)
} catch {
return handle
}
if (!Array.isArray(conditions)) {
return handle
}
// Find the condition by ID and generate semantic handle
let elseIfCount = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (condition.id === conditionId) {
if (title === 'if') {
return `condition-${blockId}-if`
}
if (title === 'else if') {
elseIfCount++
return elseIfCount === 1
? `condition-${blockId}-else-if`
: `condition-${blockId}-else-if-${elseIfCount}`
}
if (title === 'else') {
return `condition-${blockId}-else`
}
}
// Count else-ifs as we iterate
if (title === 'else if') {
elseIfCount++
}
}
// Fallback: return original handle if condition not found
return handle
}
/**
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
*/
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) {
return handle
}
// Extract the route UUID from the handle
const routeId = handle.substring('router-'.length)
// Get routes from block subBlocks
const routesValue = block.subBlocks?.routes?.value
if (!routesValue || typeof routesValue !== 'string') {
return handle
}
let routes: Array<{ id: string; title?: string }>
try {
routes = JSON.parse(routesValue)
} catch {
return handle
}
if (!Array.isArray(routes)) {
return handle
}
// Find the route by ID and generate semantic handle (1-indexed)
for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) {
return `router-${blockId}-route-${i + 1}`
}
}
// Fallback: return original handle if route not found
return handle
}
/**
* Convert source handle to semantic format for condition and router blocks
*/
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSemantic(handle, blockId, block)
}
if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSemantic(handle, blockId, block)
}
return handle
}
/**
* Extract connections for a block from edges and format as operations-style connections
* Converts internal UUID handles to semantic format for training data
*/
function extractConnectionsForBlock(
blockId: string,
edges: WorkflowState['edges']
edges: WorkflowState['edges'],
block: BlockState
): Record<string, string | string[]> | undefined {
const connections: Record<string, string[]> = {}
@@ -284,9 +400,12 @@ function extractConnectionsForBlock(
return undefined
}
// Group by source handle
// Group by source handle (converting to semantic format)
for (const edge of outgoingEdges) {
const handle = edge.sourceHandle || 'source'
let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to semantic format
handle = convertToSemanticHandle(handle, blockId, block)
if (!connections[handle]) {
connections[handle] = []
@@ -321,7 +440,7 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
// Helper to recursively sanitize a block and its children
const sanitizeBlock = (blockId: string, block: BlockState): CopilotBlockState => {
const connections = extractConnectionsForBlock(blockId, state.edges)
const connections = extractConnectionsForBlock(blockId, state.edges, block)
// For loop/parallel blocks, extract config from block.data instead of subBlocks
let inputs: Record<string, string | number | string[][] | object>
+12 -7
View File
@@ -2562,16 +2562,18 @@ export const useCopilotStore = create<CopilotStore>()(
fileAttachments,
contexts,
messageId,
queueIfBusy = true,
} = options as {
stream?: boolean
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
messageId?: string
queueIfBusy?: boolean
}
if (!workflowId) return
// If already sending a message, queue this one instead
// If already sending a message, queue this one instead unless bypassing queue
if (isSendingMessage && !activeAbortController) {
logger.warn('[Copilot] sendMessage: stale sending state detected, clearing', {
originalMessageId: messageId,
@@ -2583,12 +2585,15 @@ export const useCopilotStore = create<CopilotStore>()(
})
set({ isSendingMessage: false, abortController: null })
} else if (isSendingMessage) {
get().addToQueue(message, { fileAttachments, contexts, messageId })
logger.info('[Copilot] Message queued (already sending)', {
queueLength: get().messageQueue.length + 1,
originalMessageId: messageId,
})
return
if (queueIfBusy) {
get().addToQueue(message, { fileAttachments, contexts, messageId })
logger.info('[Copilot] Message queued (already sending)', {
queueLength: get().messageQueue.length + 1,
originalMessageId: messageId,
})
return
}
get().abortMessage({ suppressContinueOption: true })
}
const nextAbortController = new AbortController()
+1
View File
@@ -179,6 +179,7 @@ export interface CopilotActions {
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
messageId?: string
queueIfBusy?: boolean
}
) => Promise<void>
abortMessage: (options?: { suppressContinueOption?: boolean }) => void