fix(autolayout): rescue new notes from blocks they were created on top of (#6680)

This commit is contained in:
Justin Blumencranz
2026-08-13 15:56:29 -07:00
committed by GitHub
parent 1424809614
commit 44524d1b07
4 changed files with 77 additions and 2 deletions
@@ -336,6 +336,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
previousBlocks: workflowState.blocks,
})
} catch (error) {
logger.warn('Targeted autolayout failed, using default positions', {
@@ -458,4 +458,65 @@ describe('applyTargetedLayout', () => {
result.loop.position.x + loopMetrics.width
)
})
it('relocates a newly added note off a block it was created on top of', () => {
// A copilot note is born at the (0,0) placeholder, and a fresh workflow's
// start block lives at (0,0) too. The pre-edit snapshot does not contain
// the note, so the overlap counts as introduced by this edit and the note
// must be moved to the stack below the flow, not preserved as intentional.
const blocks = {
start: createBlock('start', { position: { x: 0, y: 0 } }),
note: createBlock('note', {
type: 'note',
position: { x: 0, y: 0 },
subBlocks: {
content: { id: 'content', type: 'long-input', value: 'Explains the workflow' },
},
}),
}
const result = applyTargetedLayout(blocks, [], {
changedBlockIds: ['note'],
previousBlocks: { start: blocks.start },
})
const startMetrics = getBlockMetrics(result.start)
expect(result.start.position).toEqual({ x: 0, y: 0 })
expect(result.note.position.y).toBeGreaterThanOrEqual(
result.start.position.y + startMetrics.height + DEFAULT_VERTICAL_SPACING
)
})
it('preserves a pre-existing note arrangement when an unrelated block is laid out', () => {
const blocks = {
start: createBlock('start', { position: { x: 0, y: 0 } }),
note: createBlock('note', {
type: 'note',
position: { x: 60, y: 10 },
subBlocks: {
content: { id: 'content', type: 'long-input', value: 'Deliberately parked here' },
},
}),
added: createBlock('added', { position: { x: 0, y: 0 } }),
}
const edges: Edge[] = [{ id: 'e1', source: 'start', target: 'added' }]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['added'],
previousBlocks: {
start: createBlock('start', { position: { x: 0, y: 0 } }),
note: createBlock('note', {
type: 'note',
position: { x: 60, y: 10 },
subBlocks: {
content: { id: 'content', type: 'long-input', value: 'Deliberately parked here' },
},
}),
},
})
expect(result.note.position).toEqual({ x: 60, y: 10 })
expect(result.added.position.x).toBeGreaterThan(result.start.position.x)
})
})
+14 -2
View File
@@ -35,6 +35,15 @@ export interface TargetedLayoutOptions extends LayoutOptions {
shiftSourceBlockIds?: string[]
verticalSpacing?: number
horizontalSpacing?: number
/**
* Pre-edit block snapshot used to judge whether a note's overlap with a block
* is a pre-existing arrangement (preserved) or was introduced by the edit
* being laid out (relocated). Without it, the post-edit input blocks serve as
* the baseline — which contains newly added notes at their `(0,0)` placeholder,
* so a new note dropped onto a block at the origin (e.g. a fresh workflow's
* start block) reads as intentional and is never rescued.
*/
previousBlocks?: Record<string, BlockState>
}
/**
@@ -53,6 +62,7 @@ export function applyTargetedLayout(
verticalSpacing = DEFAULT_VERTICAL_SPACING,
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
gridSize,
previousBlocks = blocks,
} = options
if (
@@ -135,8 +145,10 @@ export function applyTargetedLayout(
)
// Relocate notes only where this pass introduced an overlap, comparing against
// the original positions so pre-existing note arrangements are preserved.
resolveNoteOverlaps(blocksCopy, verticalSpacing, { previousBlocks: blocks })
// the baseline positions so pre-existing note arrangements are preserved. A
// note absent from the baseline (newly added by this edit) is always eligible
// for relocation.
resolveNoteOverlaps(blocksCopy, verticalSpacing, { previousBlocks })
return blocksCopy
}
@@ -490,6 +490,7 @@ export class WorkflowDiffEngine {
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
previousBlocks: mergedBaseline.blocks,
})
Object.entries(layoutedBlocks).forEach(([id, layoutBlock]) => {