diff --git a/apps/web/src/components/panels/ai-chat-handlers.ts b/apps/web/src/components/panels/ai-chat-handlers.ts index de12669a..d941fea3 100644 --- a/apps/web/src/components/panels/ai-chat-handlers.ts +++ b/apps/web/src/components/panels/ai-chat-handlers.ts @@ -85,6 +85,16 @@ WORKFLOW: 3. When inserting, use "after" parameter with a sibling ID to place the new node in the correct position. 4. After each operation, write 1-2 sentences summarizing what changed. +DIAGNOSING OVERLAP / STACKING BUGS — read this before "fixing" any visual overlap: +- When snapshot_layout.overlaps is non-empty, two or more siblings share screen area. Do NOT blindly enlarge heights, shrink fonts, or tweak padding — those are surface patches. +- Inspect the overlapping nodes' shared PARENT via batch_get. Look at its \`layout\` field: + • \`layout: "none"\` (or missing) → children positioned via absolute x/y. OpenPencil's renderer has a known bug where absolute-positioned children stack vertically instead of honoring x/y. This is almost always the true root cause. + • \`layout: "vertical"\` with gap=0 and children using textGrowth:"fit_content" → text can visually touch; bump \`gap\` or add padding on the children. +- Preferred fix for \`layout: "none"\` parents that contain stacked content (badges, titles, rows): + update_node(parent, { layout: "vertical", gap: 8, alignItems: "flex-start" }) + and strip the children's absolute x/y (the flex engine positions them). +- For a circle/ring with centered content: NEVER use \`layout: "none"\`. Use a frame with cornerRadius = width/2, layout:"horizontal", alignItems:"center", justifyContent:"center", children:[ the text/icon ]. + INSERT_NODE GUIDE — always include complete node data with children: - Button example: {"type":"frame","name":"My Button","width":"fill_container","height":50,"cornerRadius":8,"fill":[{"type":"solid","color":"#1877F2"}],"layout":"horizontal","gap":8,"alignItems":"center","justifyContent":"center","children":[{"type":"icon_font","name":"Icon","iconName":"facebook","width":20,"height":20,"fill":[{"type":"solid","color":"#FFFFFF"}]},{"type":"text","name":"Label","text":"Continue with Facebook","fontSize":15,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}]} - Text example: {"type":"text","name":"Title","text":"Hello","fontSize":24,"fontWeight":700,"fill":[{"type":"solid","color":"#1A1A2E"}]} diff --git a/apps/web/src/services/ai/agent-tool-executor.ts b/apps/web/src/services/ai/agent-tool-executor.ts index 987ec7c3..7c7a0fd1 100644 --- a/apps/web/src/services/ai/agent-tool-executor.ts +++ b/apps/web/src/services/ai/agent-tool-executor.ts @@ -394,11 +394,11 @@ export class AgentToolExecutor { const { getNodeBounds } = await import('@/stores/document-tree-utils'); - const buildLayout = ( - nodes: typeof children, - maxDepth: number, - depth = 0, - ): { + // Overlap accumulator — flat list of sibling pairs whose rendered bounds + // intersect. Gives the agent a text-based "screenshot equivalent" so it can + // spot layout bugs (notably `layout:"none"` parents stacking children at + // the same y) without needing a vision-capable model. + type LayoutEntry = { id: string; name?: string; type: string; @@ -407,21 +407,44 @@ export class AgentToolExecutor { width: number; height: number; children?: unknown[]; - }[] => - nodes.map((node) => { + }; + const overlaps: Array<{ parentId: string | null; a: string; b: string; reason: string }> = []; + const OVERLAP_EPS = 4; // ignore sub-pixel touches + + const detectSiblingOverlaps = ( + entries: LayoutEntry[], + parentId: string | null, + parentLayout: string | undefined, + ) => { + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const a = entries[i]; + const b = entries[j]; + if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) continue; + const xOverlap = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x); + const yOverlap = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y); + if (xOverlap > OVERLAP_EPS && yOverlap > OVERLAP_EPS) { + const reason = !parentLayout || parentLayout === 'none' + ? 'parent has layout:"none" — absolute x/y can stack children at the same position; switch parent to layout:"vertical"|"horizontal" with gap' + : `siblings overlap by ~${Math.round(xOverlap)}x${Math.round(yOverlap)} px — check gap/padding on parent`; + overlaps.push({ parentId, a: a.id, b: b.id, reason }); + } + } + } + }; + + const buildLayout = ( + nodes: typeof children, + maxDepth: number, + parentId: string | null, + parentLayout: string | undefined, + depth = 0, + ): LayoutEntry[] => { + const entries = nodes.map((node) => { // Use layout-computed bounds from SkiaEngine, fall back to stored values const computed = renderNodeMap.get(node.id); const b = computed ?? getNodeBounds(node, allChildren); - const entry: { - id: string; - name?: string; - type: string; - x: number; - y: number; - width: number; - height: number; - children?: unknown[]; - } = { + const entry: LayoutEntry = { id: node.id, name: node.name, type: node.type, @@ -431,12 +454,25 @@ export class AgentToolExecutor { height: Math.round(b.h), }; if ('children' in node && node.children?.length && depth < maxDepth) { - entry.children = buildLayout(node.children, maxDepth, depth + 1); + const childLayout = (node as { layout?: string }).layout; + entry.children = buildLayout( + node.children, + maxDepth, + node.id, + childLayout, + depth + 1, + ); } return entry; }); + detectSiblingOverlaps(entries, parentId, parentLayout); + return entries; + }; - return { success: true, data: buildLayout(children, 3) }; + const tree = buildLayout(children, 3, null, undefined); + return overlaps.length > 0 + ? { success: true, data: { tree, overlaps } } + : { success: true, data: tree }; } private async handleFindEmptySpace(args: { diff --git a/apps/web/src/services/ai/agent-tools.ts b/apps/web/src/services/ai/agent-tools.ts index 41e9d881..4e6ad03d 100644 --- a/apps/web/src/services/ai/agent-tools.ts +++ b/apps/web/src/services/ai/agent-tools.ts @@ -95,7 +95,11 @@ export function getCrudToolDefs(): ToolDef[] { { name: 'snapshot_layout', description: - 'Get a compact layout snapshot of the current page showing node positions and sizes', + 'Get a compact layout snapshot of the current page showing node positions and sizes. ' + + 'When sibling nodes visually overlap, the result includes an `overlaps` array (parentId, a, b, reason) — ' + + 'use it as a text-only screenshot-replacement to diagnose visual bugs like stacked badges or overlapping text. ' + + 'If an overlap reason mentions `layout:"none"`, fix the PARENT frame (set layout to "vertical" or "horizontal" with a gap); ' + + 'do not just resize the overlapping children.', level: TOOL_AUTH_MAP.snapshot_layout, parameters: { type: 'object', @@ -219,7 +223,11 @@ export function getDesignToolDefs(): ToolDef[] { { name: 'snapshot_layout', description: - 'Get a compact layout snapshot of the current page showing node positions and sizes', + 'Get a compact layout snapshot of the current page showing node positions and sizes. ' + + 'When sibling nodes visually overlap, the result includes an `overlaps` array (parentId, a, b, reason) — ' + + 'use it as a text-only screenshot-replacement to diagnose visual bugs like stacked badges or overlapping text. ' + + 'If an overlap reason mentions `layout:"none"`, fix the PARENT frame (set layout to "vertical" or "horizontal" with a gap); ' + + 'do not just resize the overlapping children.', level: TOOL_AUTH_MAP.snapshot_layout, parameters: { type: 'object',