From 4adfa05bdcb3194776ef7fc2a594b34a437ba4b2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:31:05 +0800 Subject: [PATCH] feat(console-ai): edit-mode empty state distinct from magic-flow build (ADR-0057 A1.b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the build surface is opened to edit an existing app (?package=X), the empty-state guidance was still the magic-flow 'describe an app to build' copy + from-scratch starters — wrong for editing a bound app. Now the build surface has two empty states, keyed on editPackageId: - magic flow (no ?package=): 'Build with AI' + build-a-CRM/tracker starters. - edit mode (?package=X): 'Editing ""' + 'what would you like to change… I'll modify this app in place' + change-oriented starters (add a field / object / dashboard / automation). App name comes from metadata (editAppLabel); falls back to a generic 'Edit this app' title until metadata loads, so a raw package id (app.xadv) never flashes. agentEmptyState + buildAgentSuggestions gain the edit branch and are exported + unit-tested (8 cases). Empty state stays string-props into ChatbotEnhanced — no plugin-chatbot change. Co-Authored-By: Claude Fable 5 --- .../app-shell/src/console/ai/AiChatPage.tsx | 79 ++++++++++++++++--- .../ai/__tests__/editModeEmptyState.test.ts | 75 ++++++++++++++++++ 2 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 packages/app-shell/src/console/ai/__tests__/editModeEmptyState.test.ts diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 27852df606..0a2f9cab3a 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -269,11 +269,31 @@ function localizeAgentLabel( * surface as data Q&A ("ask about your records"). Keyed by friendly name; falls * back to the generic empty state for custom agents. */ -function agentEmptyState( +export function agentEmptyState( t: (key: string, options?: Record) => string, agentName: string | undefined, + // ADR-0057 A1.b — when the build surface is opened to EDIT an existing app + // (`?package=`), the empty-state guidance switches from "describe an app to + // build" (magic flow) to "what do you want to change in ". `appLabel` is + // the resolved app name, or undefined until metadata loads (never a raw + // package id — see the caller). + editContext?: { appLabel?: string }, ): { title: string; description: string } { if (isBuildAgent(agentName)) { + if (editContext) { + return { + title: editContext.appLabel + ? t('console.ai.empty.editApp.title', { + defaultValue: 'Editing “{{app}}”', + app: editContext.appLabel, + }) + : t('console.ai.empty.editApp.titleGeneric', { defaultValue: 'Edit this app' }), + description: t('console.ai.empty.editApp.description', { + defaultValue: + 'What would you like to change? I’ll modify this app in place — add a field, object, view or automation, or adjust what’s already there.', + }), + }; + } return { title: t('console.ai.empty.build.title', { defaultValue: 'Build with AI' }), description: t('console.ai.empty.build.description', { @@ -1456,13 +1476,8 @@ export function ChatPane({ return hydratedMessagesToChatMessages(initialMessages); }, [initialMessages]); - const suggestions = useMemo(() => { - if (hydrated.length > 0) return undefined; - return buildAgentSuggestions(activeAgent, activeAgentLabel, t); - }, [hydrated.length, activeAgent, activeAgentLabel, t]); - - // Per-surface empty-state branding (Build = authoring, Ask = data Q&A). - const emptyState = useMemo(() => agentEmptyState(t, activeAgent), [t, activeAgent]); + // `suggestions` + `emptyState` are computed AFTER the bound-package label + // below (ADR-0057 A1.b edit mode needs the app name), near boundPackageLabel. // ADR-0013 D2: reconcile a stream-transport failure instead of blindly // retrying. Shared across chat surfaces — see useReconcileOnError. @@ -1663,6 +1678,32 @@ export function ChatPane({ return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : boundPackageId; }, [boundPackageId, metadataApps, appLabel, t]); + // ADR-0057 A1.b edit mode — the resolved name of the app being EDITED + // (`?package=`). Returns undefined until the app is found in metadata, so the + // empty-state title falls back to a generic label rather than flashing a raw + // package id (`app.xadv`) while metadata loads. + const editAppLabel = useMemo(() => { + if (!editPackageId) return undefined; + const app = (metadataApps ?? []).find( + (a) => (a as { _packageId?: string })._packageId === editPackageId, + ); + return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : undefined; + }, [editPackageId, metadataApps, appLabel, t]); + + // Per-surface empty-state branding (Build = authoring, Ask = data Q&A). On the + // build surface, `?package=` flips it to edit mode: "what do you want to + // change in " + change-oriented starters, instead of the from-scratch + // magic-flow guidance. Computed here (not earlier) so it can read the app name. + const editing = isBuildSurface && Boolean(editPackageId); + const suggestions = useMemo(() => { + if (hydrated.length > 0) return undefined; + return buildAgentSuggestions(activeAgent, activeAgentLabel, t, editing); + }, [hydrated.length, activeAgent, activeAgentLabel, t, editing]); + const emptyState = useMemo( + () => agentEmptyState(t, activeAgent, editing ? { appLabel: editAppLabel } : undefined), + [t, activeAgent, editing, editAppLabel], + ); + // A1.b bind-on-create: report the binding to the page (which re-keys the // conversation and puts `?package=` on the URL). Deferred behind `canBind` // (the conversation id must be in the URL first — see ChatPaneProps) AND @@ -2157,15 +2198,31 @@ function genericSuggestions(t: TranslationFn): string[] { ]; } -function buildAgentSuggestions( +// ADR-0057 A1.b — edit-mode starters: when the build surface is bound to an +// existing app (`?package=`), nudge toward INCREMENTAL changes to that app +// rather than describing a new system from scratch. +function editAppSuggestions(t: TranslationFn): string[] { + return [ + t('console.ai.suggestions.editApp.addField', { defaultValue: 'Add a field to one of the objects.' }), + t('console.ai.suggestions.editApp.addObject', { defaultValue: 'Add a new object and relate it to an existing one.' }), + t('console.ai.suggestions.editApp.addDashboard', { defaultValue: 'Add a dashboard for the key metrics.' }), + t('console.ai.suggestions.editApp.addAutomation', { defaultValue: 'Add an automation — an approval, a status flow, or a notification.' }), + ]; +} + +export function buildAgentSuggestions( agentName: string | undefined, agentLabel: string, t: TranslationFn, + // ADR-0057 A1.b — the build surface is editing an existing app, so offer + // change-oriented starters instead of the from-scratch authoring ones. + editing = false, ): string[] { // Alias-aware: `ask`/`data_chat` → data starters, `build`/`metadata_assistant` - // → authoring starters. Custom agents fall back to a name/label heuristic. + // → authoring starters (edit-mode variant when bound to an app). Custom agents + // fall back to a name/label heuristic. if (isAskAgent(agentName)) return dataChatSuggestions(t); - if (isBuildAgent(agentName)) return metadataAssistantSuggestions(t); + if (isBuildAgent(agentName)) return editing ? editAppSuggestions(t) : metadataAssistantSuggestions(t); const lower = (agentName ?? agentLabel).toLowerCase(); if (lower.includes('data')) return dataChatSuggestions(t); if (lower.includes('metadata')) return metadataAssistantSuggestions(t); diff --git a/packages/app-shell/src/console/ai/__tests__/editModeEmptyState.test.ts b/packages/app-shell/src/console/ai/__tests__/editModeEmptyState.test.ts new file mode 100644 index 0000000000..0c688bef56 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/editModeEmptyState.test.ts @@ -0,0 +1,75 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; + +import { agentEmptyState, buildAgentSuggestions } from '../AiChatPage'; + +// Identity translator: returns the defaultValue, interpolating `{{app}}` so the +// edit-mode named title can be asserted. Lets us test the branching without the +// i18n runtime. +const t = (_key: string, opts?: Record): string => { + let s = (opts?.defaultValue as string) ?? _key; + if (opts && 'app' in opts) s = s.replace('{{app}}', String(opts.app)); + return s; +}; + +// ADR-0057 A1.b — the build surface's empty-state guidance and starters switch +// between the magic flow (from-scratch) and edit mode (change an existing app). +describe('agentEmptyState — build magic vs edit mode', () => { + it('magic flow (no editContext): "Build with AI" / describe-an-app', () => { + const s = agentEmptyState(t, 'build', undefined); + expect(s.title).toBe('Build with AI'); + expect(s.description).toMatch(/describe/i); + }); + + it('edit mode with a known app name: title names the app', () => { + const s = agentEmptyState(t, 'build', { appLabel: 'Tiny Todo' }); + expect(s.title).toBe('Editing “Tiny Todo”'); + expect(s.description).toMatch(/change/i); + expect(s.description).toMatch(/in place/i); + }); + + it('edit mode before the app name resolves: generic edit title (never a raw id)', () => { + const s = agentEmptyState(t, 'build', { appLabel: undefined }); + expect(s.title).toBe('Edit this app'); + expect(s.title).not.toMatch(/app\./); // no raw package id like app.xadv + }); + + it('ask agent is unaffected by edit context', () => { + const s = agentEmptyState(t, 'ask', { appLabel: 'Tiny Todo' }); + expect(s.title).toBe('Ask your data'); + }); +}); + +describe('buildAgentSuggestions — magic vs edit starters', () => { + it('build + not editing: from-scratch authoring starters', () => { + const s = buildAgentSuggestions('build', 'Build', t, false); + expect(s.join(' ')).toMatch(/Build a sales CRM/); + expect(s.join(' ')).not.toMatch(/Add a field/); + }); + + it('build + editing: change-oriented starters', () => { + const s = buildAgentSuggestions('build', 'Build', t, true); + expect(s.join(' ')).toMatch(/Add a field/); + expect(s.join(' ')).toMatch(/Add a new object/); + expect(s.join(' ')).not.toMatch(/Build a sales CRM/); + }); + + it('editing defaults to false (magic starters) when omitted', () => { + expect(buildAgentSuggestions('build', 'Build', t)).toEqual( + buildAgentSuggestions('build', 'Build', t, false), + ); + }); + + it('ask agent ignores editing (always data starters)', () => { + const s = buildAgentSuggestions('ask', 'Ask', t, true); + expect(s.join(' ')).toMatch(/How many users/); + expect(s.join(' ')).not.toMatch(/Add a field/); + }); +});