diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 25cd94cd5..e18d0f97f 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -96,6 +96,7 @@ import { useReconcileOnError } from '../../hooks/useReconcileOnError'; import { chatConversationScope, chatProductOfAgent } from '../../hooks/chatScope'; import { ConversationsSidebar } from './ConversationsSidebar'; import { LiveCanvas } from './LiveCanvas'; +import { artifactStudioPath } from './artifactStudioPath'; import { BuildDebugDrawer } from './BuildDebugDrawer'; import { isConversationZh } from './conversationLanguage'; @@ -2069,6 +2070,25 @@ export function ChatPane({ onOpenBuiltApp={(appName, appSegment) => navigate(`/apps/${encodeURIComponent(appSegment ?? appName)}`)} openBuiltAppLabel={t('console.ai.openBuiltApp', { defaultValue: 'Open app' })} + // ADR-0080 D5 cold-start handoff — the PRIMARY action on a finished + // build: Studio is the built app's iteration home (direct edit + the + // same copilot conversation in the dock), so the flow's natural end + // is "step into Studio", not "leave for the published front-end". + // The package id is the canvas segment (one package = one app). + onDesignBuiltApp={(_appName, appSegment) => { + const pkg = appSegment ?? canvasApp?.segment; + if (!pkg) return; + navigate(`/studio/${encodeURIComponent(pkg)}/interfaces`); + }} + designBuiltAppLabel={t('console.ai.designBuiltApp', { defaultValue: 'Design in Studio' })} + // Artifact deep links: every artifact the agent built gets a one-click + // path to where it can be edited BY HAND (object → Data, flow → + // Automations, dashboard/page/view/app → Interfaces). Returns null for + // types with no direct-edit home (seed/dataset) → rendered as text. + getArtifactAction={(artifact, appSegment) => { + const path = artifactStudioPath(appSegment ?? canvasApp?.segment, artifact); + return path ? () => navigate(path) : null; + }} // Live lifecycle truth for draft cards: the server's pending count per // package, so reloaded conversations show Published/Publish honestly. fetchPendingDraftCount={fetchPendingDraftCount} diff --git a/packages/app-shell/src/console/ai/__tests__/artifactStudioPath.test.ts b/packages/app-shell/src/console/ai/__tests__/artifactStudioPath.test.ts new file mode 100644 index 000000000..1a56d5177 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/artifactStudioPath.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { artifactStudioPath } from '../artifactStudioPath'; + +/** + * ADR-0080 D5 — every AI-built artifact deep-links to its direct-edit home + * (the Studio pillar where it can be changed by hand, no AI needed). + */ +describe('artifactStudioPath', () => { + const pkg = 'app.rmpe'; + + it('object → Data pillar with an object surface', () => { + expect(artifactStudioPath(pkg, { type: 'object', name: 'rmpe_customer' })).toBe( + '/studio/app.rmpe/data?surface=object%3Armpe_customer', + ); + }); + + it('flow → Automations pillar (the reserved ?surface=flow: consumer gets its first producer)', () => { + expect(artifactStudioPath(pkg, { type: 'flow', name: 'customer_lost_follow_up' })).toBe( + '/studio/app.rmpe/automations?surface=flow%3Acustomer_lost_follow_up', + ); + }); + + it('dashboard / page → Interfaces pillar with their own surface', () => { + expect(artifactStudioPath(pkg, { type: 'dashboard', name: 'home_dashboard' })).toBe( + '/studio/app.rmpe/interfaces?surface=dashboard%3Ahome_dashboard', + ); + expect(artifactStudioPath(pkg, { type: 'page', name: 'welcome' })).toBe( + '/studio/app.rmpe/interfaces?surface=page%3Awelcome', + ); + }); + + it('view → the OWNING OBJECT leaf on Interfaces (views are not nav leaves)', () => { + expect(artifactStudioPath(pkg, { type: 'view', name: 'rmpe_customer.customer_list' })).toBe( + '/studio/app.rmpe/interfaces?surface=object%3Armpe_customer', + ); + }); + + it('app → Interfaces pillar home (no surface)', () => { + expect(artifactStudioPath(pkg, { type: 'app', name: 'customer_management' })).toBe( + '/studio/app.rmpe/interfaces', + ); + }); + + it('artifacts with no direct-edit home return null (seed / dataset / unknown)', () => { + expect(artifactStudioPath(pkg, { type: 'seed', name: 'rmpe_customer_sample' })).toBeNull(); + expect(artifactStudioPath(pkg, { type: 'dataset', name: 'rmpe_customer_ds' })).toBeNull(); + expect(artifactStudioPath(pkg, { type: 'mystery', name: 'x' })).toBeNull(); + }); + + it('no package id or blank name → null (never a half-built link)', () => { + expect(artifactStudioPath(undefined, { type: 'object', name: 'x' })).toBeNull(); + expect(artifactStudioPath(pkg, { type: 'object', name: ' ' })).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/ai/artifactStudioPath.ts b/packages/app-shell/src/console/ai/artifactStudioPath.ts new file mode 100644 index 000000000..7b38031a8 --- /dev/null +++ b/packages/app-shell/src/console/ai/artifactStudioPath.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Map an AI-built artifact to its direct-edit home — the Studio pillar route + * where that artifact can be changed by hand (ADR-0080 D5: every AI product + * gets a no-AI-needed edit path). + * + * object → Data pillar, the object's schema/records surface + * flow → Automations pillar (the `?surface=flow:` consumer was reserved + * for exactly this bridge — this is its first producer) + * dashboard → Interfaces pillar, that dashboard's canvas + * page → Interfaces pillar, that page's canvas + * view → Interfaces pillar, the OWNING OBJECT's canvas — views are not + * nav leaves; their name is `.`, so land on the + * object leaf the view hangs off + * app → Interfaces pillar home (the app's nav is the surface) + * + * Returns null for artifacts with no direct-edit home (seed / dataset / …) — + * callers render those as plain text, not dead links. + */ + +import { + DESIGNER_SURFACE_PARAM, + formatSurfaceParam, +} from '../../views/metadata-admin/nav-selection'; + +export interface BuiltArtifact { + type: string; + name: string; +} + +function pillarPath( + packageId: string, + pillar: 'data' | 'automations' | 'interfaces', + surface?: BuiltArtifact, +): string { + const base = `/studio/${encodeURIComponent(packageId)}/${pillar}`; + if (!surface) return base; + return `${base}?${DESIGNER_SURFACE_PARAM}=${encodeURIComponent(formatSurfaceParam(surface))}`; +} + +export function artifactStudioPath( + packageId: string | undefined, + artifact: BuiltArtifact, +): string | null { + if (!packageId) return null; + const name = artifact.name?.trim(); + if (!name) return null; + switch (artifact.type) { + case 'object': + return pillarPath(packageId, 'data', { type: 'object', name }); + case 'flow': + return pillarPath(packageId, 'automations', { type: 'flow', name }); + case 'dashboard': + return pillarPath(packageId, 'interfaces', { type: 'dashboard', name }); + case 'page': + return pillarPath(packageId, 'interfaces', { type: 'page', name }); + case 'view': { + // `.` — land on the owning object's canvas leaf. + const dot = name.indexOf('.'); + const owner = dot > 0 ? name.slice(0, dot) : name; + return pillarPath(packageId, 'interfaces', { type: 'object', name: owner }); + } + case 'app': + return pillarPath(packageId, 'interfaces'); + default: + return null; + } +} diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3afc43d02..345a9fadd 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1316,6 +1316,7 @@ const en = { publishOk: 'Published — objects are now live.', seedWarn: 'Published, but some sample data failed to load.', openBuiltApp: 'Open app', + designBuiltApp: 'Design in Studio', previewDraft: 'Preview', previewApp: 'Preview app', resizeSplit: 'Resize chat and preview', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index dc5b15be6..f5644ac53 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1387,6 +1387,7 @@ const zh = { publishOk: '已发布,对象已生效。', seedWarn: '已发布,但部分示例数据未能载入。', openBuiltApp: '打开应用', + designBuiltApp: '进入 Studio 继续完善', previewDraft: '预览', previewApp: '预览应用', resizeSplit: '调整对话与预览的宽度', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 79db0a423..ddb1ae312 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -568,6 +568,27 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void; /** Label for the open-built-app action (default "Open app"). */ openBuiltAppLabel?: string; + /** + * ADR-0080 D5 cold-start handoff — when provided, a finished build renders + * "Design in Studio" as the PRIMARY action (Studio is the built app's + * iteration home), demoting "Open app" to a secondary action. The host + * wires this to its Studio route (e.g. `navigate('/studio//interfaces')`). + */ + onDesignBuiltApp?: (appName: string, appSegment?: string) => void; + /** Label for the design-built-app action (default "Design in Studio"). */ + designBuiltAppLabel?: string; + /** + * Deep-link a built artifact to where it can be edited directly (its Studio + * pillar). Called per artifact row with the owning package id (from the + * build's own draft envelope, so reloaded conversations link too); return + * the click action for a linkable artifact, or null to render it as plain + * text — the host decides which types have a direct-edit home (e.g. + * object → Data, flow → Automations). + */ + getArtifactAction?: ( + artifact: { type: string; name: string }, + appSegment?: string, + ) => (() => void) | null; /** * ADR-0037 Live Canvas: preview the drafted app *before* it is published. * Rendered next to the build tree's Open-app action and on draft chips @@ -1169,6 +1190,9 @@ const ChatbotEnhanced = React.forwardRef( onPublishDrafts, onOpenBuiltApp, openBuiltAppLabel = 'Open app', + onDesignBuiltApp, + designBuiltAppLabel = 'Design in Studio', + getArtifactAction, onPreviewDraftApp, previewDraftLabel = 'Preview', onDraftArtifacts, @@ -2463,8 +2487,21 @@ const ChatbotEnhanced = React.forwardRef( {buildProgress ? ( tool.draftReview) + .find((dr) => dr?.packageId && dr.items?.some((i) => i.type === 'app')) + ?.packageId + } onOpenBuiltApp={onOpenBuiltApp} openBuiltAppLabel={openBuiltAppLabel} + onDesignBuiltApp={onDesignBuiltApp} + designBuiltAppLabel={designBuiltAppLabel} + getArtifactAction={getArtifactAction} onPreviewDraftApp={onPreviewDraftApp} previewDraftLabel={previewDraftLabel} waitingLabel={L.connectionWaiting} @@ -3319,8 +3356,12 @@ function BlueprintProgressPanel({ */ function BuildProgressPanel({ progress, + appSegment, onOpenBuiltApp, openBuiltAppLabel = 'Open app', + onDesignBuiltApp, + designBuiltAppLabel = 'Design in Studio', + getArtifactAction, onPreviewDraftApp, previewDraftLabel = 'Preview', waitingLabel = 'Waiting for server…', @@ -3328,8 +3369,16 @@ function BuildProgressPanel({ offlineLabel = 'Connection lost — reconnecting…', }: { progress: ChatBuildProgress; + /** The build's owning package id (from its draft envelope), for deep links. */ + appSegment?: string; onOpenBuiltApp?: (appName: string) => void; openBuiltAppLabel?: string; + onDesignBuiltApp?: (appName: string, appSegment?: string) => void; + designBuiltAppLabel?: string; + getArtifactAction?: ( + artifact: { type: string; name: string }, + appSegment?: string, + ) => (() => void) | null; onPreviewDraftApp?: (appName: string) => void; previewDraftLabel?: string; waitingLabel?: string; @@ -3348,10 +3397,10 @@ function BuildProgressPanel({ // The created `app` artifact (navigation shell) — the natural "open it" target. const builtApp = items.find((it) => it.type === 'app'); const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : isDone ? 100 : 6; - const groups = new Map(); + const groups = new Map>(); for (const it of items) { const arr = groups.get(it.type) ?? []; - arr.push(it.name.replace(/_sample$/, '')); + arr.push({ name: it.name, display: it.name.replace(/_sample$/, '') }); groups.set(it.type, arr); } const orderedTypes = [ @@ -3390,26 +3439,71 @@ function BuildProgressPanel({
    {orderedTypes.map((type) => { - const names = groups.get(type)!; + const entries = groups.get(type)!; return (
  • {BUILD_GROUP_LABEL[type] ?? type}{' '} - {names.slice(0, 6).join(', ')} - {names.length > 6 ? ` +${names.length - 6} more` : ''} + {entries.slice(0, 6).map((entry, i) => { + // Deep-link the artifact to its direct-edit home (Studio + // pillar) once the build is done — the host decides which + // types are linkable (null → plain text). Mid-build stays + // plain: half-built artifacts have nowhere stable to land. + const action = isDone + ? getArtifactAction?.({ type, name: entry.name }, appSegment) + : null; + return ( + + {i > 0 ? ', ' : null} + {action ? ( + + ) : ( + entry.display + )} + + ); + })} + {entries.length > 6 ? ` +${entries.length - 6} more` : ''}
  • ); })}
- {isDone && builtApp && (onOpenBuiltApp || onPreviewDraftApp) ? ( + {isDone && builtApp && (onDesignBuiltApp || onOpenBuiltApp || onPreviewDraftApp) ? (
+ {/* ADR-0080 D5 cold-start handoff: Studio is the built app's + iteration home, so it takes the PRIMARY slot; "Open app" (the + published front-end) demotes to a secondary. Hosts that don't + pass onDesignBuiltApp keep the old Open-app-primary layout. */} + {onDesignBuiltApp ? ( + + ) : null} {onOpenBuiltApp ? (