fix(ai): close remaining Type 0 component leaks in fallback + agent paths

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).
This commit is contained in:
Fini 2026-05-09 20:59:26 +08:00
parent e3ee765d90
commit e9dcfb5d91
5 changed files with 92 additions and 8 deletions

View file

@ -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',

View file

@ -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;

View file

@ -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 {

View file

@ -192,6 +192,17 @@ function extractSubtaskCandidates(obj: Record<string, unknown>): 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);

View file

@ -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