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:
parent
e3ee765d90
commit
e9dcfb5d91
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue