Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
69 changes: 69 additions & 0 deletions packages/app-shell/src/console/ai/artifactStudioPath.ts
Original file line number Diff line number Diff line change
@@ -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 `<object>.<view>`, 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': {
// `<object>.<view_name>` — 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;
}
}
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,7 @@ const zh = {
publishOk: '已发布,对象已生效。',
seedWarn: '已发布,但部分示例数据未能载入。',
openBuiltApp: '打开应用',
designBuiltApp: '进入 Studio 继续完善',
previewDraft: '预览',
previewApp: '预览应用',
resizeSplit: '调整对话与预览的宽度',
Expand Down
108 changes: 101 additions & 7 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,27 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
onOpenBuiltApp?: (appName: string, appSegment?: string) => 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/<pkg>/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
Expand Down Expand Up @@ -1169,6 +1190,9 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
onPublishDrafts,
onOpenBuiltApp,
openBuiltAppLabel = 'Open app',
onDesignBuiltApp,
designBuiltAppLabel = 'Design in Studio',
getArtifactAction,
onPreviewDraftApp,
previewDraftLabel = 'Preview',
onDraftArtifacts,
Expand Down Expand Up @@ -2463,8 +2487,21 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
{buildProgress ? (
<BuildProgressPanel
progress={buildProgress}
// The build's owning package, from ITS OWN draft
// envelope — present on reloaded conversations too,
// so the Studio CTA / artifact links never depend on
// live-canvas runtime state.
appSegment={
message.toolInvocations
?.map((tool) => 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}
Expand Down Expand Up @@ -3319,17 +3356,29 @@ function BlueprintProgressPanel({
*/
function BuildProgressPanel({
progress,
appSegment,
onOpenBuiltApp,
openBuiltAppLabel = 'Open app',
onDesignBuiltApp,
designBuiltAppLabel = 'Design in Studio',
getArtifactAction,
onPreviewDraftApp,
previewDraftLabel = 'Preview',
waitingLabel = 'Waiting for server…',
stalledLabel = 'Still working…',
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;
Expand All @@ -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<string, string[]>();
const groups = new Map<string, Array<{ name: string; display: string }>>();
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 = [
Expand Down Expand Up @@ -3390,26 +3439,71 @@ function BuildProgressPanel({
</div>
<ul className="space-y-1">
{orderedTypes.map((type) => {
const names = groups.get(type)!;
const entries = groups.get(type)!;
return (
<li key={type} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
<span className="min-w-0 text-muted-foreground">
<span className="font-medium text-foreground">{BUILD_GROUP_LABEL[type] ?? type}</span>{' '}
{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 (
<React.Fragment key={entry.name}>
{i > 0 ? ', ' : null}
{action ? (
<button
type="button"
onClick={action}
className="rounded-sm text-primary underline decoration-primary/40 underline-offset-2 hover:decoration-primary"
data-testid={`build-artifact-link-${type}-${entry.name}`}
>
{entry.display}
</button>
) : (
entry.display
)}
</React.Fragment>
);
})}
{entries.length > 6 ? ` +${entries.length - 6} more` : ''}
</span>
</li>
);
})}
</ul>
{isDone && builtApp && (onOpenBuiltApp || onPreviewDraftApp) ? (
{isDone && builtApp && (onDesignBuiltApp || onOpenBuiltApp || onPreviewDraftApp) ? (
<div className="mt-3 flex items-center gap-2">
{/* 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 ? (
<button
type="button"
onClick={() => onDesignBuiltApp(builtApp.name, appSegment)}
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
data-testid="build-progress-design-app"
>
{designBuiltAppLabel}
<ArrowRight className="size-3.5" />
</button>
) : null}
{onOpenBuiltApp ? (
<button
type="button"
onClick={() => onOpenBuiltApp(builtApp.name)}
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md px-3 text-xs font-medium',
onDesignBuiltApp
? 'border hover:bg-muted'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
)}
data-testid="build-progress-open-app"
>
{openBuiltAppLabel}
Expand Down
Loading
Loading