From d68228951f7a21b3cc5272404db82bb3f93581e7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:06:33 +0800 Subject: [PATCH] feat(ai-build): surface deferred rules as a distinct card section + richer wait hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two magic-flow confirm-step polish items observed in live testing: 1. Deferred/"not yet built" business rules (e.g. row-level "技师只看自己的工单" that the build defers to a later flow/permission pass) were mixed in with ordinary design-note assumptions in the Proposed-plan card, so users could mistake a deferred rule for one that was actually built. `classifyAssumptions` now keyword-splits assumptions, and the card renders the deferred ones under a distinct "待补 / Not yet built" section, apart from regular assumptions. 2. The propose/apply wait could run minutes under load while the rotating hint pool was small enough to repeat noticeably. Expanded the "Designing your app…" hint pool (relationships, forms, defaults, dashboard, review) so a long wait keeps showing fresh, concrete progress instead of looping the same few lines. Both are presentational + i18n (zh+en). Tests: plugin-chatbot 156 (+14: classifyAssumptions split + deferred section render + hint pool), app-shell 941. plugin-chatbot builds clean. Co-Authored-By: Claude Opus 4.8 --- .../app-shell/src/console/ai/AiChatPage.tsx | 6 + .../src/layout/ConsoleFloatingChatbot.tsx | 13 + .../plugin-chatbot/src/ChatbotEnhanced.tsx | 233 +++++++++++++--- .../src/__tests__/ChatbotEnhanced.test.tsx | 264 +++++++++++++++++- 4 files changed, 482 insertions(+), 34 deletions(-) diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 93a53eb9f2..1a7637c5c7 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -1248,7 +1248,12 @@ function ChatPane({ t('console.ai.designingPlanHint.data', { defaultValue: 'Mapping out the data you’ll track…' }), t('console.ai.designingPlanHint.objects', { defaultValue: 'Shaping objects and their fields…' }), t('console.ai.designingPlanHint.relations', { defaultValue: 'Connecting related records…' }), + t('console.ai.designingPlanHint.lookups', { defaultValue: 'Setting up relationships and lookups…' }), t('console.ai.designingPlanHint.views', { defaultValue: 'Planning the screens and views…' }), + t('console.ai.designingPlanHint.forms', { defaultValue: 'Laying out forms and lists…' }), + t('console.ai.designingPlanHint.defaults', { defaultValue: 'Adding sensible defaults and validations…' }), + t('console.ai.designingPlanHint.dashboard', { defaultValue: 'Sketching a dashboard to track it…' }), + t('console.ai.designingPlanHint.review', { defaultValue: 'Double-checking the structure hangs together…' }), t('console.ai.designingPlanHint.finalize', { defaultValue: 'Pulling the plan together…' }), ], toolDetailsHidden: t('console.ai.toolDetailsHidden'), @@ -1348,6 +1353,7 @@ function ChatPane({ planTitleLabel={t('console.ai.planTitle', { defaultValue: 'Proposed plan' })} planQuestionsLabel={t('console.ai.planQuestions', { defaultValue: 'Confirm before building' })} planAssumptionsLabel={t('console.ai.planAssumptions', { defaultValue: 'Assumptions' })} + planDeferredLabel={t('console.ai.planDeferred', { defaultValue: 'Not yet built' })} planApproveHintLabel={t('console.ai.planApproveHint', { defaultValue: 'Reply to approve or adjust this plan.', })} diff --git a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx index d71565c6dc..f1f91d5d2a 100644 --- a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx +++ b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx @@ -119,7 +119,12 @@ function buildChatLocale( '梳理需要记录的数据…', '设计对象与字段…', '关联相关记录…', + '配置关系与查找字段…', '规划页面与视图…', + '布置表单与列表…', + '补充默认值与校验…', + '规划一个看板来跟踪…', + '复核整体结构是否自洽…', '汇总成完整方案…', ], toolDetailsHidden: '已隐藏工具参数和原始结果,仅保留过程摘要。', @@ -145,6 +150,7 @@ function buildChatLocale( planTitle: '方案预览', planQuestions: '搭建前请确认', planAssumptions: '假设', + planDeferred: '待补 / 暂未搭建', planApproveHint: '回复以确认或调整该方案。', planApprove: '开始搭建', planAdjust: '调整方案', @@ -192,7 +198,12 @@ function buildChatLocale( 'Mapping out the data you’ll track…', 'Shaping objects and their fields…', 'Connecting related records…', + 'Setting up relationships and lookups…', 'Planning the screens and views…', + 'Laying out forms and lists…', + 'Adding sensible defaults and validations…', + 'Sketching a dashboard to track it…', + 'Double-checking the structure hangs together…', 'Pulling the plan together…', ], toolDetailsHidden: 'Tool inputs and raw results are hidden in this view.', @@ -218,6 +229,7 @@ function buildChatLocale( planTitle: 'Proposed plan', planQuestions: 'Confirm before building', planAssumptions: 'Assumptions', + planDeferred: 'Not yet built', planApproveHint: 'Reply to approve or adjust this plan.', planApprove: 'Build it', planAdjust: 'Adjust', @@ -740,6 +752,7 @@ function ChatbotInner({ planTitleLabel={locale.planTitle} planQuestionsLabel={locale.planQuestions} planAssumptionsLabel={locale.planAssumptions} + planDeferredLabel={locale.planDeferred} planApproveHintLabel={locale.planApproveHint} planApproveLabel={locale.planApprove} planAdjustLabel={locale.planAdjust} diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 61b9f47e88..918b1b545b 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { cn } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; -import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles } from 'lucide-react'; +import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; import type { ChatStatus } from 'ai'; import { humanizeToolName, @@ -543,6 +543,14 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes haystack.includes(m))) { + deferred.push(text); + } else { + designNotes.push(text); + } + } + return { designNotes, deferred }; +} + function shouldRenderDetailedTool(tool: ChatToolInvocation): boolean { const state = getToolState(tool); return ( @@ -975,6 +1052,7 @@ const ChatbotEnhanced = React.forwardRef( planExtendLabel = 'Adding to existing app', planQuestionsLabel = 'Confirm before building', planAssumptionsLabel = 'Assumptions', + planDeferredLabel = 'Not yet built', planApproveHintLabel = 'Reply to approve or adjust this plan.', planApproveLabel = 'Build it', planAdjustLabel = 'Adjust', @@ -1701,22 +1779,56 @@ const ChatbotEnhanced = React.forwardRef( {bits.join(' · ')} ) : null; })()} - {tool.proposedPlan.assumptions.length > 0 ? ( -
- - {planAssumptionsLabel} - - {tool.proposedPlan.assumptions.map((a, idx) => ( -
- · - {a} -
- ))} -
- ) : null} + {/* Assumptions split into ordinary design notes vs. business + rules the build is explicitly DEFERRING ("待补 / Not yet + built"). The deferred set gets its own labelled, tinted + section with an hourglass so a user can't mistake a + still-to-come rule for delivered behaviour (improvement 2). */} + {(() => { + const { designNotes, deferred } = classifyAssumptions( + tool.proposedPlan!.assumptions, + ); + return ( + <> + {designNotes.length > 0 ? ( +
+ + {planAssumptionsLabel} + + {designNotes.map((a, idx) => ( +
+ · + {a} +
+ ))} +
+ ) : null} + {deferred.length > 0 ? ( +
+ + + {planDeferredLabel} + + {deferred.map((a, idx) => ( +
+ · + {a} +
+ ))} +
+ ) : null} + + ); + })()} {tool.proposedPlan.questions.length > 0 ? (
0 ? elapsedMs : 0; + const step = stepMs > 0 ? stepMs : DESIGNING_HINT_ROTATE_MS; + const stage = Math.floor(safeElapsed / step); + return Math.min(stage, hintCount - 1); +} + /** * Friendly in-progress indicator for the build agent's `propose_blueprint` * step. Because that call is a SINGLE long, atomic LLM request (no token * stream, no partial results), a bare elapsed timer made it look like the UI * might be stuck. This pairs the live `ToolRunningTimer` with a short lead-in - * ("Designing your app…") and a hint that ROTATES every few seconds, so the - * wait visibly "moves" and reads as deliberate work. The rotation is purely - * presentational — it is NOT claiming real sub-step progress. An empty `hints` - * array (or a single entry) just pins the lead-in + timer with no rotation. + * ("Designing your app…"), a hint that ADVANCES through the design stages as the + * wait grows, and a row of step dots filled up to the current stage so the wait + * visibly "moves forward". The staging is purely presentational — it is NOT + * claiming real sub-step progress or a percentage; it just clamps on the final + * stage rather than looping. An empty `hints` array (or a single entry) just + * pins the lead-in + timer with no rotation or dots. */ function BuildProposalProgressHint({ label, @@ -2645,17 +2790,22 @@ function BuildProposalProgressHint({ hints: string[]; offlineLabel: string; }) { - const [index, setIndex] = React.useState(0); + // Re-read the real elapsed clock once per stage interval; the visible stage is + // DERIVED from it (via the pure selector) so the hint and the step dots advance + // off one source of truth. The live seconds timer beside this ticks every 1s on + // its own, so the strip keeps "moving" between stage changes. + const [elapsedMs, setElapsedMs] = React.useState(0); React.useEffect(() => { - if (hints.length <= 1) return; // nothing to rotate through - const id = setInterval( - () => setIndex((i) => (i + 1) % hints.length), - DESIGNING_HINT_ROTATE_MS, - ); + if (hints.length <= 1) return; // nothing to advance through + const start = Date.now(); + const id = setInterval(() => setElapsedMs(Date.now() - start), DESIGNING_HINT_ROTATE_MS); return () => clearInterval(id); }, [hints.length]); - // Guard the index against a shrinking `hints` list (label change mid-rotation). - const hint = hints.length > 0 ? hints[index % hints.length] : undefined; + const index = selectDesignHintIndex(elapsedMs, hints.length); + const hint = index >= 0 ? hints[index] : undefined; + // Show the lightweight step dots only once there are real stages to track and + // we won't crowd the strip (cap the dot count so a long custom list stays sane). + const showDots = hints.length > 1 && hints.length <= 12; return ( {label} {hint ? ( // `key` on the hint text restarts the fade each time it swaps, so the - // rotation reads as a gentle change rather than an instant flicker. + // stage change reads as a gentle transition rather than an instant flicker. {hint} ) : null} + {showDots ? ( + + {hints.map((_, i) => ( + + ))} + + ) : null} ); diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx index 719fc1deeb..b8c000fbc7 100644 --- a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx @@ -9,7 +9,12 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; +import { + ChatbotEnhanced, + classifyAssumptions, + selectDesignHintIndex, + type ChatMessage, +} from '../ChatbotEnhanced'; describe('ChatbotEnhanced (AI Elements composition)', () => { beforeEach(() => { @@ -1230,3 +1235,260 @@ describe('ChatbotEnhanced — propose_blueprint in-progress design hints', () => expect(container.querySelector('[data-tool-running-timer]')).not.toBeNull(); }); }); + +// Improvement 2: a plan's `assumptions` mix neutral design notes with business +// rules the build is explicitly DEFERRING ("…will be added later / 需要后续单独补"). +// `classifyAssumptions` splits them so the card can surface the deferred set +// apart, and the user can't mistake a still-to-come rule for delivered behaviour. +describe('classifyAssumptions (deferred vs. design-note split)', () => { + it('routes explicit "deferred" markers (zh + en) to `deferred`, keeps the rest as design notes', () => { + const { designNotes, deferred } = classifyAssumptions([ + '设备通过所属客户建立归属关系', // neutral design note + '技师只能看到分配给自己的工单,将在后续 Flow / 权限配置中实现', // deferred (将在 / 后续) + 'Each device belongs to one customer', // neutral design note + 'Role-based access for technicians will be added later', // deferred (will be added / later) + '审批流确认后一起补', // deferred (一起补) + '需要后续单独补权限/流程配置', // deferred (需要后续) + ]); + expect(designNotes).toEqual([ + '设备通过所属客户建立归属关系', + 'Each device belongs to one customer', + ]); + expect(deferred).toEqual([ + '技师只能看到分配给自己的工单,将在后续 Flow / 权限配置中实现', + 'Role-based access for technicians will be added later', + '审批流确认后一起补', + '需要后续单独补权限/流程配置', + ]); + }); + + it('matches deferral markers case-insensitively and is not fooled by a bare "flow"/"permission" mention', () => { + const { designNotes, deferred } = classifyAssumptions([ + 'Approvals are NOT YET wired up', // deferred — uppercase marker + 'A flow runs on every new work order', // built rule that merely mentions "flow" → design note + 'Permissions follow the org role', // built rule that mentions permission → design note + ]); + expect(deferred).toEqual(['Approvals are NOT YET wired up']); + expect(designNotes).toEqual([ + 'A flow runs on every new work order', + 'Permissions follow the org role', + ]); + }); + + it('trims and drops blank/whitespace assumptions and tolerates non-strings', () => { + const { designNotes, deferred } = classifyAssumptions([ + ' One shelf for now ', + '', + ' ', + // @ts-expect-error — guard against malformed backend data + null, + '暂不实现导出', // deferred (暂不) + ]); + expect(designNotes).toEqual(['One shelf for now']); + expect(deferred).toEqual(['暂不实现导出']); + }); + + it('handles the all-design-notes and all-deferred extremes', () => { + expect(classifyAssumptions(['plain a', 'plain b'])).toEqual({ + designNotes: ['plain a', 'plain b'], + deferred: [], + }); + expect(classifyAssumptions(['deferred later', '稍后补充'])).toEqual({ + designNotes: [], + deferred: ['deferred later', '稍后补充'], + }); + }); +}); + +// Improvement 2 (render): the plan card surfaces the deferred assumptions in a +// distinct "Not yet built" section, separate from the ordinary assumptions list. +describe('ChatbotEnhanced — proposed plan deferred-assumptions section', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + const planWithAssumptions = (assumptions: string[]): ChatMessage[] => [ + { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'propose_blueprint', + state: 'output-available', + proposedPlan: { + summary: 'A field-service app', + objects: [{ name: 'work_order', label: 'Work Order', fieldCount: 4 }], + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], + assumptions, + }, + }, + ], + }, + ]; + + it('renders deferred assumptions in their own "Not yet built" section, design notes in the normal list', () => { + render( + , + ); + const deferred = screen.getByTestId('proposed-plan-deferred'); + expect(deferred).toHaveTextContent('NOT_YET_BUILT'); + expect(deferred).toHaveTextContent('将在后续权限配置中实现'); + // The neutral note stays in the ordinary assumptions group, NOT the deferred box. + const notes = screen.getByTestId('proposed-plan-assumptions'); + expect(notes).toHaveTextContent('Devices belong to a customer'); + expect(notes).not.toHaveTextContent('将在后续权限配置中实现'); + }); + + it('omits the deferred section entirely when no assumption is deferred', () => { + render( + , + ); + expect(screen.getByTestId('proposed-plan-assumptions')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-deferred')).not.toBeInTheDocument(); + }); + + it('omits the ordinary assumptions section when every assumption is deferred', () => { + render( + , + ); + expect(screen.getByTestId('proposed-plan-deferred')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-assumptions')).not.toBeInTheDocument(); + }); +}); + +// Improvement 4: the design-wait hint should read as steady forward progress on a +// long (multi-minute) propose_blueprint call. `selectDesignHintIndex` advances one +// stage per interval, then CLAMPS on the last hint instead of wrapping — so it never +// looks like it restarted, and there is no fake percentage. +describe('selectDesignHintIndex (elapsed → design stage)', () => { + const step = 3500; + it('advances one stage per interval', () => { + expect(selectDesignHintIndex(0, 5, step)).toBe(0); + expect(selectDesignHintIndex(step - 1, 5, step)).toBe(0); + expect(selectDesignHintIndex(step, 5, step)).toBe(1); + expect(selectDesignHintIndex(step * 2, 5, step)).toBe(2); + expect(selectDesignHintIndex(step * 3, 5, step)).toBe(3); + }); + + it('clamps on the final stage rather than wrapping back to the start', () => { + // Way past the last stage (a multi-minute wait) — pins the last hint, never loops. + expect(selectDesignHintIndex(step * 4, 5, step)).toBe(4); + expect(selectDesignHintIndex(step * 50, 5, step)).toBe(4); + expect(selectDesignHintIndex(step * 999, 10, step)).toBe(9); + }); + + it('returns -1 for an empty list and pins index 0 for a single hint', () => { + expect(selectDesignHintIndex(0, 0, step)).toBe(-1); + expect(selectDesignHintIndex(step * 5, 0, step)).toBe(-1); + expect(selectDesignHintIndex(0, 1, step)).toBe(0); + expect(selectDesignHintIndex(step * 5, 1, step)).toBe(0); + }); + + it('treats negative / non-finite elapsed as stage 0 (no crash)', () => { + expect(selectDesignHintIndex(-100, 5, step)).toBe(0); + expect(selectDesignHintIndex(Number.NaN, 5, step)).toBe(0); + // Infinity is not finite → guarded to stage 0 rather than NaN/overflow. + expect(selectDesignHintIndex(Number.POSITIVE_INFINITY, 5, step)).toBe(0); + }); +}); + +// Improvement 4 (render): the running-proposal indicator shows step dots that fill +// up to the current stage and advances forward through a longer hint pool. +describe('ChatbotEnhanced — design-wait staging (dots + forward progress)', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const runningProposal: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'build me a CRM' }, + { + id: 'a1', + role: 'assistant', + content: '', + streaming: true, + toolInvocations: [ + { toolCallId: 't1', toolName: 'propose_blueprint', state: 'input-available' }, + ], + }, + ]; + + it('renders one step dot per hint and advances the hint forward as the wait grows', () => { + const { container } = render( + , + ); + const dots = () => container.querySelector('[data-testid="build-proposal-progress-dots"]'); + const hint = () => container.querySelector('[data-testid="build-proposal-progress"]'); + expect(dots()?.children.length).toBe(3); + expect(hint()?.textContent).toContain('HINT_A'); + act(() => { + vi.advanceTimersByTime(3500); + }); + expect(hint()?.textContent).toContain('HINT_B'); + act(() => { + vi.advanceTimersByTime(3500); + }); + expect(hint()?.textContent).toContain('HINT_C'); + }); + + it('clamps on the last hint on a long wait instead of looping back', () => { + const { container } = render( + , + ); + const hint = () => container.querySelector('[data-testid="build-proposal-progress"]'); + act(() => { + vi.advanceTimersByTime(3500 * 8); // well past the last stage + }); + expect(hint()?.textContent).toContain('HINT_B'); + expect(hint()?.textContent).not.toContain('HINT_A'); + }); + + it('shows no step dots when rotation is disabled (single hint)', () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-testid="build-proposal-progress"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="build-proposal-progress-dots"]')).toBeNull(); + }); +});