From 696d71db376d056ec147721c7d6aebb364cefba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 08:49:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(console-ai):=20explicit=20"Open=20in=20Bui?= =?UTF-8?q?lder=20=E2=86=92"=20ask=E2=86=92build=20handoff=20(ADR-0057=20P?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the ask agent declines an authoring request it calls the cloud suggest_builder tool; the console renders that structured decline as an explicit "Open in Builder →" action that opens the build surface seeded with the handoff prompt (ADR-0063 decline-and-redirect — explicit, never a silent re-route). - plugin-chatbot: detectBuilderHandoff lifts { status:build_handoff, prompt, packageId? } onto the tool invocation; ChatbotEnhanced renders the card and calls a new onOpenBuilder prop (disabled when unwired). - app-shell: the full-page AiChatPage (ask) and the 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. Conversation-context transfer is a later upgrade (cloud#817); v1 carries the build prompt + optional package. Unit-tested (detect + card render + click + disabled-without-host). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PJKsT9dvjxgVSPYid8EtTQ --- .changeset/adr-0057-p4-open-in-builder.md | 26 +++++++++ .../app-shell/src/console/ai/AiChatPage.tsx | 36 ++++++++++++ .../src/layout/ConsoleFloatingChatbot.tsx | 11 ++++ .../plugin-chatbot/src/ChatbotEnhanced.tsx | 55 +++++++++++++++++++ .../src/__tests__/ChatbotEnhanced.test.tsx | 42 ++++++++++++++ .../src/__tests__/mapMessages.test.ts | 49 +++++++++++++++++ packages/plugin-chatbot/src/mapMessages.ts | 23 ++++++++ 7 files changed, 242 insertions(+) create mode 100644 .changeset/adr-0057-p4-open-in-builder.md diff --git a/.changeset/adr-0057-p4-open-in-builder.md b/.changeset/adr-0057-p4-open-in-builder.md new file mode 100644 index 0000000000..06a06f60fe --- /dev/null +++ b/.changeset/adr-0057-p4-open-in-builder.md @@ -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. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 336c1c72c2..1a2b2a7764 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -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(); @@ -825,6 +829,20 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro // via useDeferredFirstSend. See that hook for the full race. const pendingFirstMessageRef = useRef(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. @@ -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. @@ -1461,6 +1496,7 @@ export function ChatPane({ window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer')} + onOpenBuilder={openBuilder} surface="plain" maxHeight="100%" headerSlot={headerSlot} diff --git a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx index d26951bc82..5f1314619d 100644 --- a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx +++ b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx @@ -692,6 +692,17 @@ function ChatbotInner({ <> 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, diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index febc6c27ed..97d53d9d4f 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -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 { @@ -587,6 +599,17 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes 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"). */ @@ -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. @@ -1151,6 +1176,9 @@ const ChatbotEnhanced = React.forwardRef( 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', @@ -1648,6 +1676,8 @@ const ChatbotEnhanced = React.forwardRef( 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) @@ -1880,6 +1910,31 @@ const ChatbotEnhanced = React.forwardRef( "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 ? ( +
+ + + {builderHandoffTitleLabel} + +

{tool.builderHandoff.prompt}

+ +
+ ) : null} {tool.proposedPlan ? (
{ 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(); + 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(); + expect(screen.getByTestId('builder-handoff-open')).toBeDisabled(); + }); + const planMessage = (questions: string[]): ChatMessage[] => [ { id: 'a1', diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts index e139fe597e..4a37d88fc7 100644 --- a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts @@ -12,6 +12,7 @@ import { uiMessagesToChatMessages, detectDraftResult, detectProposedPlan, + detectBuilderHandoff, buildProgressFromDraftReview, } from '../mapMessages'; @@ -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([]); diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index a2c8ca17a4..882f39418b 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -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; @@ -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: @@ -470,6 +492,7 @@ function extractToolInvocations( draftReview, proposedPlan, proposedChanges, + builderHandoff, } satisfies ChatToolInvocation; }); }