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
26 changes: 26 additions & 0 deletions .changeset/adr-0057-p4-open-in-builder.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
"@object-ui/plugin-chatbot": minor
"@object-ui/app-shell": minor
---

feat(console-ai): explicit "Open in Builder →" ask→build handoff (ADR-0057 P4)

When the `ask` agent declines an app-authoring request it now calls the cloud
`suggest_builder` tool (structured decline). The console renders that as an
explicit **"Open in Builder →"** action that opens the full-page build surface
seeded with the handoff prompt — ADR-0063 decline-and-redirect: an explicit,
user-initiated switch, never a silent re-route into authoring.

- `@object-ui/plugin-chatbot`: `detectBuilderHandoff` lifts the
`{ status:'build_handoff', prompt, packageId? }` result onto the tool
invocation; `ChatbotEnhanced` renders the "Open in Builder →" card and calls a
new `onOpenBuilder` prop (disabled when no host wires it).
- `@object-ui/app-shell`: the full-page `AiChatPage` (`ask`) and the console FAB
wire `onOpenBuilder` to navigate to `/ai/build?package=…&handoffPrompt=…`; the
build surface seeds that prompt as its first message (auto-sent once the
conversation is minted), and the URL-mirror strips `?handoffPrompt` so a reload
never re-sends it. Full ask-conversation context transfer is a later upgrade
(cloud#817); v1 carries the build prompt + optional package.

Requires the cloud `suggest_builder` signal (service-ai-studio) to light up; the
console degrades cleanly (no card) without it.
36 changes: 36 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -567,6 +567,10 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
// so its metadata reads scope to that app and edits bind to it from the first
// message (the agent seeds it as the conversation's active package).
const editPackageId = searchParams.get('package')?.trim() || undefined;
// ADR-0057 P4 — a build prompt handed off from the `ask` surface's
// "Open in Builder →" (suggest_builder). Seeded as the build surface's first
// message below; the URL-mirror strips it once the conversation is minted.
const handoffPrompt = searchParams.get('handoffPrompt')?.trim() || undefined;
const navigate = useNavigate();
const { setContext } = useNavigationContext();

Expand DownExpand Up@@ -825,6 +829,20 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
// via useDeferredFirstSend. See that hook for the full race.
const pendingFirstMessageRef = useRef<PendingFirstMessage | null>(null);

// ADR-0057 P4 — seed the handed-off build prompt as this surface's first
// message. Only on the BUILD surface (the handoff target), once, before the
// conversation id is minted; `useDeferredFirstSend` (in ChatPane) replays it
// the moment the id resolves. The URL-mirror then rewrites to
// `/ai/build/:id?package=…`, dropping `?handoffPrompt`, so a reload never
// re-sends it.
const handoffSeededRef = useRef(false);
useEffect(() => {
if (!handoffPrompt || handoffSeededRef.current) return;
if (!activeAgent || agentRouteName(activeAgent) !== 'build') return;
handoffSeededRef.current = true;
pendingFirstMessageRef.current = { content: handoffPrompt };
}, [handoffPrompt, activeAgent]);

const handleSent = useCallback(
(firstUserMessage?: string) => {
// New user turn → bump sidebar list so the row's preview/timestamp refreshes.
Expand DownExpand Up@@ -1091,6 +1109,23 @@ export function ChatPane({
// always-available. Shown only when there's more than one agent to switch to.
const showAgentLauncher = agents.length > 1;

// ADR-0057 P4 — the `ask` agent declined an authoring request (suggest_builder).
// Open the full-page BUILD surface seeded with the handoff prompt (carried as
// `?handoffPrompt=`, auto-sent on arrival; ADR-0063 decline-and-redirect — an
// explicit, user-initiated switch, never a silent re-route). Prefer the
// handoff's own packageId, else this surface's edit package.
const openBuilder = useCallback(
(handoff: { prompt: string; packageId?: string }) => {
const params = new URLSearchParams();
const pkg = handoff.packageId || editPackageId;
if (pkg) params.set('package', pkg);
if (handoff.prompt) params.set('handoffPrompt', handoff.prompt);
const qs = params.toString();
navigate(`/ai/build${qs ? `?${qs}` : ''}`);
},
[navigate, editPackageId],
);

// ── ADR-0037 Live Canvas ────────────────────────────────────────────────
// When a build session drafts an `app`, open the split-view canvas: the
// drafted app rendered as-if-published (`?preview=draft`) beside the chat.
Expand DownExpand Up@@ -1461,6 +1496,7 @@ export function ChatPane({
<ChatbotEnhanced
className="min-h-0 flex-1 bg-background md:max-w-5xl"
onUpgrade={() => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer')}
onOpenBuilder={openBuilder}
surface="plain"
maxHeight="100%"
headerSlot={headerSlot}
Expand Down
11 changes: 11 additions & 0 deletions packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -692,6 +692,17 @@ function ChatbotInner({
<>
<FloatingChatbot
onUpgrade={() => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer')}
// ADR-0057 P4 — the ambient `ask` FAB declined an authoring request
// (suggest_builder). Open the full-page BUILD surface seeded with the
// handoff prompt (ADR-0063 decline-and-redirect — explicit, never a
// silent re-route). The FAB has no package of its own; use the handoff's.
onOpenBuilder={(handoff) => {
const params = new URLSearchParams();
if (handoff.packageId) params.set('package', handoff.packageId);
if (handoff.prompt) params.set('handoffPrompt', handoff.prompt);
const qs = params.toString();
navigate(`/ai/build${qs ? `?${qs}` : ''}`);
}}
floatingConfig={{
position: 'bottom-right',
defaultOpen,
Expand Down
55 changes: 55 additions & 0 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,6 +307,18 @@ export interface ChatToolInvocation {
summary?: string;
changes: Array<{ verb: string; object?: string; field?: string; type?: string; name?: string; details?: string }>;
};
/**
* ADR-0057 P4 — the `ask` agent's structured decline-and-redirect. Set when
* the ask agent called `suggest_builder` (`{ handoff:'build', prompt,
* packageId? }`) because the user asked to author. `mapMessages.ts` lifts the
* handoff here so the chat renders an explicit "Open in Builder →" action that
* opens the build surface seeded with `prompt` — never a silent re-route into
* authoring (ADR-0063).
*/
builderHandoff?: {
prompt: string;
packageId?: string;
};
}

export interface ChatSource {
Expand DownExpand Up@@ -587,6 +599,17 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
* but unlisted. Hosts switch the canvas to the real app URL.
*/
onBuildMaterialized?: (appName: string) => void;
/**
* ADR-0057 P4 — invoked when the user clicks "Open in Builder →" on an `ask`
* agent's `suggest_builder` decline. The host opens the build surface seeded
* with the handoff prompt/package (never a silent re-route; ADR-0063). Absent
* → the button is disabled (nothing to route to).
*/
onOpenBuilder?: (handoff: { prompt: string; packageId?: string }) => void;
/** Heading for the ADR-0057 P4 "Open in Builder" handoff card (default "Build this in the Builder"). */
builderHandoffTitleLabel?: string;
/** Label for the handoff card's primary action button (default "Open in Builder →"). */
builderHandoffOpenLabel?: string;
/** Label for the publish-drafts button (default "Publish"). */
publishDraftsLabel?: string;
/** Label for the published-state badge that replaces the button (default "Published"). */
Expand DownExpand Up@@ -978,6 +1001,8 @@ function shouldRenderDetailedTool(tool: ChatToolInvocation): boolean {
// a propose_blueprint result there instead of collapsing it into a chip.
Boolean(tool.proposedPlan) ||
Boolean(tool.proposedChanges) ||
// ADR-0057 P4 — the "Open in Builder →" handoff lives in the detailed body.
Boolean(tool.builderHandoff) ||
// A completed propose_blueprint that produced NO structured plan still needs
// the detailed body — that's where the fallback "Build it" confirm gate
// renders so the user is never left guessing the confirmation phrase.
Expand DownExpand Up@@ -1151,6 +1176,9 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
verifiedLabel = 'Verified',
nextStepsLabel = "What's next",
planTitleLabel = 'Proposed plan',
builderHandoffTitleLabel = 'Build this in the Builder',
builderHandoffOpenLabel = 'Open in Builder →',
onOpenBuilder,
planExtendLabel = 'Adding to existing app',
planQuestionsLabel = 'Confirm before building',
planAssumptionsLabel = 'Assumptions',
Expand DownExpand Up@@ -1648,6 +1676,8 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
Boolean(tool.draftReview && tool.draftReview.items.length > 0) ||
Boolean(tool.proposedPlan) ||
Boolean(tool.proposedChanges) ||
// ADR-0057 P4 — open so the "Open in Builder →" action is visible.
Boolean(tool.builderHandoff) ||
// Fallback confirm gate (unstructured proposal) must open so its
// "Build it" button is visible without an extra click.
isUnstructuredBuildProposal(tool)
Expand DownExpand Up@@ -1880,6 +1910,31 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
"Completed" step) gives the user the Airtable-style confirm gate:
see what will be built, then approve or adjust. Nothing is live
yet. */}
{/* ADR-0057 P4 — the `ask` agent declined an authoring request and
called `suggest_builder`. Render an explicit "Open in Builder →"
that hands the request to the build surface seeded with the
prompt (ADR-0063 decline-and-redirect — never a silent re-route). */}
{tool.builderHandoff ? (
<div
className="flex flex-col gap-2 border-t bg-muted/20 px-3 py-2.5"
data-testid="builder-handoff"
>
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/80">
<Sparkles className="size-3.5" />
{builderHandoffTitleLabel}
</span>
<p className="text-xs text-muted-foreground">{tool.builderHandoff.prompt}</p>
<button
type="button"
onClick={() => onOpenBuilder?.(tool.builderHandoff!)}
disabled={!onOpenBuilder}
data-testid="builder-handoff-open"
className="inline-flex w-fit items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{builderHandoffOpenLabel}
</button>
</div>
) : null}
{tool.proposedPlan ? (
<div
className="flex flex-col gap-2 border-t bg-muted/20 px-3 py-2.5"
Expand Down
42 changes: 42 additions & 0 deletions packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,48 @@ describe('ChatbotEnhanced (AI Elements composition)', () => {
expect(screen.getByText(/Track loans separately/)).toBeInTheDocument();
});

it('renders the "Open in Builder →" handoff card and fires onOpenBuilder (ADR-0057 P4)', () => {
const onOpenBuilder = vi.fn();
const messages: ChatMessage[] = [
{
id: 'a1',
role: 'assistant',
content: '',
toolInvocations: [
{
toolCallId: 't1',
toolName: 'suggest_builder',
state: 'output-available',
builderHandoff: { prompt: 'Add a priority field to tasks', packageId: 'com.acme.crm' },
},
],
},
];
render(<ChatbotEnhanced messages={messages} onOpenBuilder={onOpenBuilder} />);
expect(screen.getByTestId('builder-handoff')).toBeInTheDocument();
expect(screen.getByText(/Add a priority field to tasks/)).toBeInTheDocument();
fireEvent.click(screen.getByTestId('builder-handoff-open'));
expect(onOpenBuilder).toHaveBeenCalledWith({
prompt: 'Add a priority field to tasks',
packageId: 'com.acme.crm',
});
});

it('disables "Open in Builder" when no host wired onOpenBuilder', () => {
const messages: ChatMessage[] = [
{
id: 'a1',
role: 'assistant',
content: '',
toolInvocations: [
{ toolCallId: 't1', toolName: 'suggest_builder', state: 'output-available', builderHandoff: { prompt: 'x' } },
],
},
];
render(<ChatbotEnhanced messages={messages} />);
expect(screen.getByTestId('builder-handoff-open')).toBeDisabled();
});

const planMessage = (questions: string[]): ChatMessage[] => [
{
id: 'a1',
Expand Down
49 changes: 49 additions & 0 deletions packages/plugin-chatbot/src/__tests__/mapMessages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import {
uiMessagesToChatMessages,
detectDraftResult,
detectProposedPlan,
detectBuilderHandoff,
buildProgressFromDraftReview,
} from '../mapMessages';

Expand DownExpand Up@@ -589,6 +590,54 @@ describe('proposedPlan detection (propose_blueprint)', () => {
});
});

describe('builderHandoff detection (ADR-0057 P4 — suggest_builder)', () => {
it('lifts the { status:build_handoff, prompt, packageId } envelope', () => {
const h = detectBuilderHandoff({
status: 'build_handoff',
handoff: 'build',
prompt: 'Add a priority field to tasks',
packageId: 'com.acme.crm',
});
expect(h).toEqual({ prompt: 'Add a priority field to tasks', packageId: 'com.acme.crm' });
});

it('parses a JSON-string result and the Vercel { type:text, value } wrapper', () => {
const json = JSON.stringify({ status: 'build_handoff', prompt: 'Build a CRM' });
expect(detectBuilderHandoff(json)).toEqual({ prompt: 'Build a CRM' });
expect(detectBuilderHandoff({ type: 'text', value: json })).toEqual({ prompt: 'Build a CRM' });
});

it('omits packageId when absent/blank and trims the prompt', () => {
expect(detectBuilderHandoff({ status: 'build_handoff', prompt: ' hi ' })).toEqual({ prompt: 'hi' });
expect(detectBuilderHandoff({ status: 'build_handoff', prompt: 'x', packageId: ' ' })).toEqual({ prompt: 'x' });
});

it('ignores non-handoff results and an empty prompt', () => {
expect(detectBuilderHandoff({ status: 'blueprint_proposed' })).toBeUndefined();
expect(detectBuilderHandoff({ status: 'build_handoff', prompt: ' ' })).toBeUndefined();
expect(detectBuilderHandoff({ foo: 1 })).toBeUndefined();
expect(detectBuilderHandoff(undefined)).toBeUndefined();
});

it('attaches builderHandoff to the mapped tool invocation', () => {
const [msg] = uiMessagesToChatMessages([
{
id: 'm1',
role: 'assistant',
parts: [
{
type: 'tool-suggest_builder',
toolCallId: 't1',
state: 'output-available',
output: { status: 'build_handoff', prompt: 'Add a field', packageId: 'p.kg' },
},
],
},
] as never);
expect(msg.toolInvocations?.[0]?.builderHandoff).toEqual({ prompt: 'Add a field', packageId: 'p.kg' });
});
});

describe('uiMessagesToChatMessages', () => {
it('returns [] for empty / non-array input', () => {
expect(uiMessagesToChatMessages([])).toEqual([]);
Expand Down
23 changes: 23 additions & 0 deletions packages/plugin-chatbot/src/mapMessages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,27 @@ export interface ProposedChanges {
}>;
}

/**
* ADR-0057 P4 — the `ask` agent's `suggest_builder` structured decline. Result
* envelope `{ status:'build_handoff', handoff:'build', prompt, packageId? }`.
* Lifted so the chat renders an explicit "Open in Builder →" action that opens
* the build surface seeded with `prompt` (never a silent re-route; ADR-0063).
*/
export interface BuilderHandoff {
prompt: string;
packageId?: string;
}

export function detectBuilderHandoff(result: unknown): BuilderHandoff | undefined {
const obj = parseResultEnvelope(result);
if (!obj || obj.status !== 'build_handoff') return undefined;
const prompt = typeof obj.prompt === 'string' ? obj.prompt.trim() : '';
if (!prompt) return undefined;
const rawPkg = (obj as { packageId?: unknown }).packageId;
const packageId = typeof rawPkg === 'string' && rawPkg.trim().length > 0 ? rawPkg.trim() : undefined;
return { prompt, ...(packageId ? { packageId } : {}) };
}

export function detectProposedChanges(result: unknown): ProposedChanges | undefined {
const obj = parseResultEnvelope(result);
if (!obj || obj.status !== 'changes_proposed') return undefined;
Expand DownExpand Up@@ -437,6 +458,7 @@ function extractToolInvocations(
const draftReview = detectDraftResult(result);
const proposedPlan = detectProposedPlan(result);
const proposedChanges = detectProposedChanges(result);
const builderHandoff = detectBuilderHandoff(result);
// Promote a dangling `input-*` state to a terminal one so a reloaded
// conversation never shows "Running" forever (the server doesn't always
// snapshot the terminal tool state). Two cases:
Expand DownExpand Up@@ -470,6 +492,7 @@ function extractToolInvocations(
draftReview,
proposedPlan,
proposedChanges,
builderHandoff,
} satisfies ChatToolInvocation;
});
}
Expand Down