From 094cec7d8d0985f5b9e543769cc9a6cdee982fdb Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 9 May 2026 20:59:32 +0800 Subject: [PATCH] fix(ai): unwrap redundant section-root when Type 0 plan has 1 subtask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: for Type 0 component plans (Notification Card / Profile Card / …) the orchestrator pre-inserts a page rootFrame named after the component, then the sub-agent emits its own section-root frame as the only child. Result is a visible "Notification Card → Notification Card" double wrap in the layers panel and a wasted layout depth that does nothing visual. The double wrap was confirmed in the 2026-05-09 end-to-end test of the notification-card prompt: depth-0 = orchestrator rootFrame (role=card), depth-1 = sub-agent wrapper (also role=card), actual children at depth-2. What: new unwrapSingleComponentSectionRoot pass added as Phase 4c right after the mobile-status-bar dedup (mutually exclusive: that runs only on mobile, this runs only on component-shaped plans). Conservative match — only fires when: - plan.subtasks.length === 1, AND - plan.rootFrame is narrow (≤480) and auto-height (<480 or 0), AND - the orchestrator rootFrame has exactly 1 frame child, AND - that child's id has the sub-agent section-root suffix (`-root` / `-section`) OR the child copied the parent's name. When the conditions hold, hoist the wrapper's children up via store.moveNode (preserving order) and remove the wrapper. Multi-section pages, dashboards, and mobile screens are untouched — early-out on the plan.subtasks.length / width / height checks. 1070 / 1070 AI tests still pass; unit-testing this against the live Zustand store is awkward, the integration verification will land via the next end-to-end notification-card run. --- apps/web/src/services/ai/orchestrator.ts | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/web/src/services/ai/orchestrator.ts b/apps/web/src/services/ai/orchestrator.ts index 924d7d6ac..b0fdc9711 100644 --- a/apps/web/src/services/ai/orchestrator.ts +++ b/apps/web/src/services/ai/orchestrator.ts @@ -103,6 +103,50 @@ function removeDuplicateStatusBars(rootNodes: FrameNode[]): void { } } +/** + * Type 0 component plans always have exactly 1 subtask, and the sub-agent + * conventionally emits a "section root frame" inside the orchestrator's + * pre-inserted page rootFrame. For multi-section pages that wrapper is the + * section container; for a Type 0 single-component design it's a redundant + * "Notification Card → Notification Card" double wrap that shows up in the + * layers panel and inflates layout depth for no visual benefit. + * + * This pass runs only for component-shaped plans (narrow + auto-height). + * If the orchestrator rootFrame ends up with exactly one child frame whose + * id was assigned the sub-agent section-root suffix (`-root` / `-section`) + * OR whose name copies the parent name, hoist that child's children up to + * the rootFrame and delete the wrapper. Conservative on any other shape — + * never touches multi-section pages. + */ +function unwrapSingleComponentSectionRoot(rootNodes: FrameNode[], plan: OrchestratorPlan): void { + if (plan.subtasks.length !== 1) return; + if (plan.rootFrame.width > 480) return; + if (typeof plan.rootFrame.height === 'number' && plan.rootFrame.height >= 480) return; + const store = useDocumentStore.getState(); + for (const rn of rootNodes) { + const root = store.getNodeById(rn.id); + if (!root || root.type !== 'frame' || !root.children || root.children.length !== 1) continue; + const wrapper = root.children[0]; + if (wrapper.type !== 'frame') continue; + const wrapperChildren = + 'children' in wrapper && Array.isArray(wrapper.children) ? wrapper.children : []; + if (wrapperChildren.length === 0) continue; + const wrapperName = ('name' in wrapper ? (wrapper as { name?: string }).name : '') ?? ''; + const wrapperId = wrapper.id; + const looksLikeSectionRoot = + wrapperId.endsWith('-root') || + wrapperId.endsWith('-section') || + (root.name && wrapperName === root.name); + if (!looksLikeSectionRoot) continue; + // Snapshot children ids in order before mutation, then move each one + // up to the rootFrame and remove the wrapper. Re-read the wrapper node + // between moves so the indices stay consistent with the live tree. + const childIds = wrapperChildren.map((c) => c.id); + childIds.forEach((cid, i) => store.moveNode(cid, root.id, i)); + store.removeNode(wrapper.id); + } +} + function isAIDuplicateStatusBar(node: PenNode): boolean { if (node.type !== 'frame') return false; if ('role' in node && (node as { role?: string }).role === 'status-bar') return false; @@ -1140,6 +1184,12 @@ export async function executeOrchestration( removeDuplicateStatusBars(rootNodes); } + // -- Phase 4c: Unwrap single-component section root (Type 0) -- + // Runs only for component-shape plans (narrow + auto-height) and + // is a no-op for everything else, so mutually exclusive with the + // mobile dedup above. Must also run BEFORE height adjustment. + unwrapSingleComponentSectionRoot(rootNodes, plan); + // Height adjustment runs after duplicate removal for both animated and // non-animated paths so the frame size reflects the cleaned node tree. if (dashboardColumnIds) {