From e9dcfb5d91cfe1739823955e57db311784528fd1 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 9 May 2026 20:59:26 +0800 Subject: [PATCH] fix(ai): close remaining Type 0 component leaks in fallback + agent paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Codex stop-time review #3 flagged "Type 0 component handling is incomplete". The earlier C1 fix (orchestrator-plan-classify helper + isMobileFullScreen heuristic) covered the orchestrator path, but four more places still bucketed narrow widths (≤480 / ≤500) as mobile and mishandled component-shaped plans. What: - agent-tool-executor.ts: replace `width<=500 ? 375 : 1200` bucket on setGenerationCanvasWidth with the inserted node's actual width — a 400-wide profile card now estimates text against 400, not 375. - design-type-presets.ts: add 'component' to DesignType union with width=400, height=0, and a single-section default. detectDesignType matches "X card / X badge / X chip / ..." prompts BEFORE the mobile / dashboard check, so the parse-failure fallback returns a 400px component instead of a 1200px landing-page for "design a profile card". Disqualified when prompt also names a screen / page. - orchestrator-prompt-optimizer.ts: 3 spots — platform selection now uses preset.type==='mobile-screen' (component groups with webapp, not mobile, since it has no status bar / bottom nav); compact prompt rules and subtask hint get a component branch ("Use width=400 height=0, exactly 1 subtask, no chrome"); fallback height map gives components a single 200px region instead of 800. - orchestrator-planning.ts: buildFallbackHeights treats narrow + auto-height plans as component-shape and emits 200px sections, preventing the prior "812 / 1 = 812-tall card" output. 2 new tests pin: (a) "design a clean profile card" → 400×0 single "Component" subtask with 200px region; (b) "design a card screen page" must NOT shortcut to component (screen/page disqualifier holds). --- .../orchestrator-prompt-optimizer.test.ts | 19 ++++++++++++ .../src/services/ai/agent-tool-executor.ts | 11 +++++-- .../src/services/ai/design-type-presets.ts | 28 ++++++++++++++++- .../src/services/ai/orchestrator-planning.ts | 11 +++++++ .../ai/orchestrator-prompt-optimizer.ts | 31 ++++++++++++++++--- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts index f93bbbe22..ad8fca3d4 100644 --- a/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts +++ b/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts @@ -138,6 +138,25 @@ describe('buildFallbackPlanFromPrompt', () => { expect(plan.subtasks[1]?.elements).toContain('All remaining main UI content'); }); + it('detects component prompts and emits a 400-wide single-subtask plan (Type 0)', () => { + // Regression for Codex stop-time review: fallback path must not classify + // "X card" / "X badge" prompts as landing-page (1200x0) when AI parsing + // fails — that produces a desktop-wide page with multi-section sub-agents + // for what was meant to be a single 400px card. + const plan = buildFallbackPlanFromPrompt('design a clean profile card with avatar'); + expect(plan.rootFrame.width).toBe(400); + expect(plan.rootFrame.height).toBe(0); + expect(plan.subtasks).toHaveLength(1); + expect(plan.subtasks[0]?.label).toBe('Component'); + expect(plan.subtasks[0]?.region.height).toBe(200); + }); + + it('does NOT misclassify "X card screen" as a component (must stay landing-page or mobile)', () => { + const plan = buildFallbackPlanFromPrompt('design a card screen page'); + // "screen" / "page" disqualifier prevents the component shortcut. + expect(plan.rootFrame.width).not.toBe(400); + }); + it('uses design.md background and style-guide name when designMd is present', () => { const designMd: DesignMdSpec = { raw: '# Test', diff --git a/apps/web/src/services/ai/agent-tool-executor.ts b/apps/web/src/services/ai/agent-tool-executor.ts index fd60953c6..d1e2fb0e4 100644 --- a/apps/web/src/services/ai/agent-tool-executor.ts +++ b/apps/web/src/services/ai/agent-tool-executor.ts @@ -621,8 +621,15 @@ export class AgentToolExecutor { const { insertStreamingNode, resetGenerationRemapping, setGenerationCanvasWidth } = await import('@/services/ai/design-canvas-ops'); resetGenerationRemapping(); - const isMobile = (node as any).width && (node as any).width <= 500; - setGenerationCanvasWidth(isMobile ? 375 : 1200); + // Use the inserted node's actual width as the text-estimation canvas + // hint instead of bucketing into 375/1200. The bucket misclassifies + // Type 0 components (a 400-wide profile card etc.) as 375-wide + // mobile, which under-allocates text width during height estimation. + const nodeWidth = + typeof (node as { width?: unknown }).width === 'number' + ? (node as { width: number }).width + : null; + setGenerationCanvasWidth(nodeWidth && nodeWidth > 0 ? nodeWidth : 1200); const insertRecursive = (n: PenNode, pid: string | null) => { const ch = 'children' in n && Array.isArray(n.children) ? [...n.children] : []; const nodeForInsert = { ...n } as PenNode; diff --git a/apps/web/src/services/ai/design-type-presets.ts b/apps/web/src/services/ai/design-type-presets.ts index 42760d7b4..9e211a4fe 100644 --- a/apps/web/src/services/ai/design-type-presets.ts +++ b/apps/web/src/services/ai/design-type-presets.ts @@ -1,4 +1,4 @@ -export type DesignType = 'mobile-screen' | 'desktop-screen' | 'landing-page'; +export type DesignType = 'mobile-screen' | 'desktop-screen' | 'landing-page' | 'component'; export interface DesignTypePreset { type: DesignType; @@ -10,6 +10,18 @@ export interface DesignTypePreset { defaultSections: string[]; } +/** + * Component triggers — when the prompt names one atomic UI piece without a + * surrounding screen ("X card", "X badge", "X chip", etc.). Mirrors the + * Type 0 list in `pen-ai-skills/skills/phases/planning/design-type.md`. + * + * Match policy: word boundary + the noun must lead OR follow the qualifier + * to keep noisy mid-sentence hits out (e.g. "shopping cart screen" should + * not match "cart" as a component). + */ +const COMPONENT_TRIGGER_RE = + /\b(?:[a-z一-鿿]+\s+)?(card|badge|chip|tile|tag|pill|toggle|switch|modal|dialog|tooltip|popover|sheet|widget|avatar|stepper|stat|metric)(?:\s+(?:design|component|widget))?\b/i; + /** * Minimal fallback design type detection. * @@ -20,6 +32,20 @@ export interface DesignTypePreset { * This fallback only needs to pick a reasonable width/height/section set. */ export function detectDesignType(prompt: string): DesignTypePreset { + // Single-component prompts (Type 0 — see design-type.md). Checked BEFORE + // mobile/dashboard so a "profile card" prompt doesn't fall through to + // landing-page (1200px) when AI parsing fails — the user gets a + // sensibly-sized 400px component instead. + if (COMPONENT_TRIGGER_RE.test(prompt) && !/screen|page|app|网页|页面/i.test(prompt)) { + return { + type: 'component', + width: 400, + height: 0, + rootHeight: 0, + defaultSections: ['Component'], + }; + } + // Explicit mobile indicators (NOT "app" alone — too ambiguous) if (/mobile|手机|phone|移动端|ios|android/i.test(prompt)) { return { diff --git a/apps/web/src/services/ai/orchestrator-planning.ts b/apps/web/src/services/ai/orchestrator-planning.ts index c7674c0cc..803208944 100644 --- a/apps/web/src/services/ai/orchestrator-planning.ts +++ b/apps/web/src/services/ai/orchestrator-planning.ts @@ -192,6 +192,17 @@ function extractSubtaskCandidates(obj: Record): unknown[] { function buildFallbackHeights(fallback: OrchestratorPlan, count: number): number[] { if (count <= 0) return []; + // Mobile screen: split the fixed 812 viewport evenly across sections. + // Component (narrow + auto-height): use a single section sized for a + // typical card (200px) — 812/n would be a misleading "tall mobile screen" + // height for a Type 0 component. + // Desktop / landing page: weighted allocation over the explicit height. + const isComponentShape = + fallback.rootFrame.width <= 480 && + (fallback.rootFrame.height === 0 || fallback.rootFrame.height === undefined); + if (isComponentShape) { + return Array.from({ length: count }, () => Math.floor(200)); + } if (fallback.rootFrame.width <= 500) { const perSection = Math.floor((fallback.rootFrame.height || 812) / count); return Array.from({ length: count }, () => perSection); diff --git a/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts b/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts index 3b2a95a72..6b7eedd59 100644 --- a/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts +++ b/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts @@ -137,7 +137,10 @@ export function buildFallbackPlanFromPrompt( const designMdBg = designMd ? inferDesignMdBackground(designMd) : null; // Try to select a style guide based on prompt keywords (only when no design.md) - const platform = preset.width <= 500 ? 'mobile' : 'webapp'; + // Style-guide platform: only `mobile-screen` is genuinely mobile. + // `component` (Type 0) is small but takes webapp-style design tokens + // (no status bar / bottom nav / safe area), so it groups with webapp. + const platform = preset.type === 'mobile-screen' ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); const guide = designMd ? null : selectStyleGuide(styleGuideRegistry, { tags, platform }); @@ -163,11 +166,14 @@ export function buildFallbackPlanFromPrompt( const sectionCount = Math.max(1, labels.length); // Mobile: split height evenly (no weighted allocation — sub-agent decides actual proportions) + // Component: single ~200px region (typical card height before content fills in) // Desktop: use standard weighted allocation let heights: number[]; if (preset.type === 'mobile-screen') { const perSection = Math.floor(preset.height / sectionCount); heights = labels.map(() => perSection); + } else if (preset.type === 'component') { + heights = labels.map(() => 200); } else { const totalHeight = preset.height || (sectionCount >= 4 ? 4000 : 800); heights = allocateSectionHeights(totalHeight, sectionCount); @@ -271,7 +277,10 @@ export function buildPlanningStyleGuideContext( } const preset = detectDesignType(prompt); - const platform = preset.width <= 500 ? 'mobile' : 'webapp'; + // Style-guide platform: only `mobile-screen` is genuinely mobile. + // `component` (Type 0) is small but takes webapp-style design tokens + // (no status bar / bottom nav / safe area), so it groups with webapp. + const platform = preset.type === 'mobile-screen' ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); const tier = resolveModelProfile(model).tier; const ranked = rankStyleGuidesForPrompt(tags, platform); @@ -355,7 +364,10 @@ export function buildCompactPlanningPrompt( designMd?: DesignMdSpec, ): CompactPlanningPrompt { const preset = detectDesignType(prompt); - const platform = preset.width <= 500 ? 'mobile' : 'webapp'; + // Style-guide platform: only `mobile-screen` is genuinely mobile. + // `component` (Type 0) is small but takes webapp-style design tokens + // (no status bar / bottom nav / safe area), so it groups with webapp. + const platform = preset.type === 'mobile-screen' ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); const designMdBg = designMd ? inferDesignMdBackground(designMd) : null; const selectedGuide = designMd ? null : selectStyleGuide(styleGuideRegistry, { tags, platform }); @@ -371,7 +383,9 @@ export function buildCompactPlanningPrompt( ? 'Create 2-4 cohesive subtasks for one mobile app screen. Group related UI together.' : preset.type === 'desktop-screen' ? 'Create 2-5 cohesive workspace sections. Keep related dashboard panels together.' - : 'Create 4-8 scrollable page sections in top-to-bottom order.'; + : preset.type === 'component' + ? 'Create exactly 1 subtask for this single component (no surrounding screen, no chrome).' + : 'Create 4-8 scrollable page sections in top-to-bottom order.'; const mobileRules = preset.type === 'mobile-screen' ? [ @@ -379,7 +393,14 @@ export function buildCompactPlanningPrompt( 'Do NOT create a status bar section. The status bar is inserted separately.', 'Use width=375 and height=812 on the root frame.', ] - : ['Use width=1200 and height=0 on the root frame.']; + : preset.type === 'component' + ? [ + 'This is a single component (Type 0), not a screen.', + 'Do NOT create a status bar, navigation, or footer section.', + 'Use width=400 and height=0 on the root frame.', + 'Use exactly 1 subtask for the component itself.', + ] + : ['Use width=1200 and height=0 on the root frame.']; const styleRule = designMd ? `Use styleGuideName="${DESIGN_MD_STYLE_GUIDE_NAME}" and rootFrame background ${backgroundColor} (from the user's design.md — overrides any catalog default).` : selectedGuide