From 81c0777df8381dcf1f166208bbe574d5620b0af1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:01:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio):=20ADR-0033=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20draft=20review=20surface=20(chat=20=E2=86=92=20desi?= =?UTF-8?q?gner=20=E2=86=92=20generic=20diff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the AI metadata-authoring loop in Studio. The framework (Phases A+C) stages every AI change as a DRAFT; this lets a human see and review them. plugin-chatbot: - mapMessages detects draft envelopes ({status:'drafted', type, name} single + apply_blueprint {drafted:[…]} batch; peels the Vercel {type:text,value} wrapper) and lifts targets onto ChatToolInvocation.draftReview. - ChatbotEnhanced renders a "Review N change(s)" button (onReviewDraft prop). app-shell: - assistantBus review channel (requestReview/requestAssistantReview); ConsoleFloatingChatbot wires the button; a navigator in AppContent routes to /apps/:appName/metadata/:type/:name?review=1. - ResourceEditPage honours ?review=1: force-reloads the pending draft and opens the review/diff. - New DraftReviewPanel — generic, type-agnostic draft↔published structural diff reusing LayeredDiff.computeDiffRows, so every type gets a review (object keeps its richer per-field review). Nothing is published — the human still clicks Publish. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0033-phase-b-draft-review.md | 21 +++ .../app-shell/src/assistant/assistantBus.ts | 35 ++++- packages/app-shell/src/console/AppContent.tsx | 26 +++- .../src/layout/ConsoleFloatingChatbot.tsx | 12 +- .../metadata-admin/DraftReviewPanel.test.tsx | 54 ++++++++ .../views/metadata-admin/DraftReviewPanel.tsx | 126 ++++++++++++++++++ .../views/metadata-admin/ResourceEditPage.tsx | 82 +++++++++++- .../src/views/metadata-admin/i18n.ts | 7 + .../plugin-chatbot/src/ChatbotEnhanced.tsx | 39 +++++- .../src/__tests__/mapMessages.test.ts | 47 +++++++ packages/plugin-chatbot/src/mapMessages.ts | 68 ++++++++-- 11 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 .changeset/adr-0033-phase-b-draft-review.md create mode 100644 packages/app-shell/src/views/metadata-admin/DraftReviewPanel.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx diff --git a/.changeset/adr-0033-phase-b-draft-review.md b/.changeset/adr-0033-phase-b-draft-review.md new file mode 100644 index 000000000..d6e97e532 --- /dev/null +++ b/.changeset/adr-0033-phase-b-draft-review.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-chatbot': minor +'@object-ui/app-shell': minor +--- + +feat(studio): ADR-0033 Phase B — draft review surface (chat → designer → generic diff) + +Closes the AI metadata-authoring loop in Studio. The framework (ADR-0033 Phases A + C) makes the assistant stage every change as a DRAFT; this lets a human see and review those drafts. + +**`@object-ui/plugin-chatbot`** + +- `mapMessages` now detects the framework's draft envelopes — `{ status:'drafted', type, name, … }` (single) and `{ status:'drafted', drafted:[{type,name}] }` (apply_blueprint batch) — and lifts the reviewable targets onto `ChatToolInvocation.draftReview` (mirrors the existing HITL `pendingActionId` path; the Vercel `{type:'text',value}` wrapper is peeled). `blueprint_proposed` is intentionally not surfaced (no draft yet). +- `ChatbotEnhanced` renders a **"Review N change(s)"** button on drafted tool results, driven by a new `onReviewDraft` callback prop. + +**`@object-ui/app-shell`** + +- `assistantBus` gains a review channel (`requestReview` / `requestAssistantReview`); `ConsoleFloatingChatbot` wires the chat button to it; a small navigator inside `AppContent` (which knows the app base) routes to `/apps/:appName/metadata/:type/:name?review=1`. +- `ResourceEditPage` honours `?review=1`: it force-reloads the pending draft (covers the case where the AI drafted the item after the page mounted) and opens the review/diff. +- New **`DraftReviewPanel`** — a generic, type-agnostic draft↔published structural diff (added / changed / removed by key), reusing `LayeredDiff`'s `computeDiffRows`. It gives **every** metadata type (view, dashboard, flow, …) a real "what will publishing change" review, surfaced as a toolbar affordance + sheet whenever a draft exists. The object designer keeps its richer per-field review. + +Nothing is published by any of this — the human still clicks Publish. diff --git a/packages/app-shell/src/assistant/assistantBus.ts b/packages/app-shell/src/assistant/assistantBus.ts index 4c78d394e..e5572e2b6 100644 --- a/packages/app-shell/src/assistant/assistantBus.ts +++ b/packages/app-shell/src/assistant/assistantBus.ts @@ -39,23 +39,39 @@ export interface AssistantEditorContext { fields?: AssistantEditorField[]; } +/** A metadata item the chat has asked the host to open in review/diff. */ +export interface AssistantReviewTarget { + type: string; + name: string; +} + export interface AssistantSnapshot { /** What the user is currently editing, or null when no designer is active. */ editor: AssistantEditorContext | null; /** Monotonic counter — bumped each time a surface requests the chat to open. */ openSeq: number; + /** + * Monotonic counter — bumped each time the chat asks the host to open a + * drafted item in review (ADR-0033 Phase B). The host (which knows the app + * base) watches this and navigates to the designer. + */ + reviewSeq: number; + /** The item to review, set alongside the latest `reviewSeq` bump. */ + reviewTarget: AssistantReviewTarget | null; } let editor: AssistantEditorContext | null = null; let openSeq = 0; +let reviewSeq = 0; +let reviewTarget: AssistantReviewTarget | null = null; // Cached snapshot — its reference only changes on a real state change so // useSyncExternalStore doesn't loop. -let snapshot: AssistantSnapshot = { editor, openSeq }; +let snapshot: AssistantSnapshot = { editor, openSeq, reviewSeq, reviewTarget }; const listeners = new Set<() => void>(); function commit(): void { - snapshot = { editor, openSeq }; + snapshot = { editor, openSeq, reviewSeq, reviewTarget }; for (const l of listeners) l(); } @@ -85,6 +101,16 @@ export const assistantBus = { openSeq += 1; commit(); }, + /** + * Ask the host to open `target` in the designer's review/diff (ADR-0033 + * Phase B). The chat calls this from the "Review N change(s)" affordance; + * a navigator that knows the app base performs the routing. + */ + requestReview(target: AssistantReviewTarget): void { + reviewSeq += 1; + reviewTarget = target; + commit(); + }, }; /** Subscribe a component to the assistant bus snapshot. */ @@ -115,3 +141,8 @@ export function useRegisterAssistantEditor(ctx: AssistantEditorContext | null): export function requestAssistantOpen(): void { assistantBus.requestOpen(); } + +/** Ask the host to open a drafted item in the designer's review/diff. */ +export function requestAssistantReview(target: AssistantReviewTarget): void { + assistantBus.requestReview(target); +} diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 8a347c192..c5fd29e41 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -9,7 +9,8 @@ */ import { Routes, Route, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom'; -import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { useAssistant } from '../assistant/assistantBus'; import { ModalForm } from '@object-ui/plugin-form'; import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; import { toast } from 'sonner'; @@ -83,6 +84,28 @@ interface AppContentProps { extraRoutesNoApp?: ReactNode; } +/** + * Bridges the global chat's "Review N change(s)" affordance (ADR-0033 Phase B) + * to the metadata designer. The chat publishes a review target on `assistantBus`; + * this navigator — which lives inside the app router and knows the app base — + * routes to `/apps/:appName/metadata/:type/:name?review=1`, where the designer + * reloads the pending draft and opens its review/diff. + */ +function DraftReviewNavigator({ appName }: { appName: string | undefined }) { + const { reviewSeq, reviewTarget } = useAssistant(); + const navigate = useNavigate(); + const lastSeq = useRef(reviewSeq); + useEffect(() => { + if (reviewSeq === lastSeq.current || !reviewTarget || !appName) return; + lastSeq.current = reviewSeq; + const { type, name } = reviewTarget; + navigate( + `/apps/${appName}/metadata/${encodeURIComponent(type)}/${encodeURIComponent(name)}?review=1`, + ); + }, [reviewSeq, reviewTarget, appName, navigate]); + return null; +} + export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) { const [connectionState, setConnectionState] = useState('disconnected'); const { user, getAuthConfig } = useAuth(); @@ -370,6 +393,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = /> + }> diff --git a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx index 8b29dcca5..e3bf04f6a 100644 --- a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx +++ b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx @@ -32,7 +32,7 @@ import { import { Share2, SquarePen } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; import { useChatConversation, type HydratedUIMessage } from '../hooks'; -import { useAssistant, type AssistantEditorContext } from '../assistant/assistantBus'; +import { useAssistant, requestAssistantReview, type AssistantEditorContext } from '../assistant/assistantBus'; /** * Display names for the built-in platform agents. The backend ships English @@ -92,6 +92,7 @@ function buildChatLocale( title: `${appLabel} 智能助手`, newChat: '开启新对话', share: '分享对话', + reviewDraft: (n: number) => `查看 ${n} 项变更`, suggestions, }; } @@ -116,6 +117,7 @@ function buildChatLocale( title: `${appLabel} Assistant`, newChat: 'New chat', share: 'Share conversation', + reviewDraft: (n: number) => `Review ${n} change${n === 1 ? '' : 's'}`, suggestions, }; } @@ -437,6 +439,14 @@ function ChatbotInner({ toolApproveLabel="Approve & run" toolDenyLabel="Reject" toolDenyReason="Operator rejected from chat" + onReviewDraft={(items) => { + // ADR-0033 Phase B: open the first drafted item in the designer's + // review/diff. The remaining items stay drafted and surface their + // own review when opened. The host navigator (AppContent) knows the + // app base and performs the routing. + if (items[0]) requestAssistantReview(items[0]); + }} + toolReviewLabel={locale.reviewDraft} /> {conversationId && ( { + it('counts added + changed + removed top-level keys', () => { + // label (changed), columns (changed), object (removed), icon (added) = 4 + expect(computeDraftChangeCount(published, draft)).toBe(4); + }); + + it('is 0 when draft equals published', () => { + expect(computeDraftChangeCount(published, { ...published })).toBe(0); + }); + + it('treats a brand-new item (no published baseline) as all-added', () => { + expect(computeDraftChangeCount(null, { a: 1, b: 2 })).toBe(2); + }); +}); + +describe('DraftReviewPanel', () => { + it('renders one row per changed key and omits unchanged keys', () => { + render(); + const panel = screen.getByTestId('draft-review-panel'); + expect(panel).toBeTruthy(); + // changed/added/removed keys appear; an unchanged key would not — here all differ. + expect(panel.textContent).toContain('label'); + expect(panel.textContent).toContain('columns'); + expect(panel.textContent).toContain('object'); // removed + expect(panel.textContent).toContain('icon'); // added + // status labels (en) render + expect(panel.textContent).toMatch(/Added/); + expect(panel.textContent).toMatch(/Removed/); + expect(panel.textContent).toMatch(/Changed/); + }); + + it('shows the empty state when nothing differs', () => { + render(); + expect(screen.queryByTestId('draft-review-panel')).toBeNull(); + expect(screen.getByText(/No changes vs the published version/i)).toBeTruthy(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx b/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx new file mode 100644 index 000000000..ff22c8c2b --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx @@ -0,0 +1,126 @@ +/** + * 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. + * + * DraftReviewPanel — ADR-0033 Phase B, §5. + * + * A GENERIC, type-agnostic review/diff: it compares a pending DRAFT against the + * last-published value and lists added / changed / removed top-level keys. It + * works for ANY metadata type (view, dashboard, flow, …) — the object designer + * keeps its richer per-field review (`ObjectFormCanvas`); this is the host-level + * fallback so every type gets a real "what will publishing change" view. + * + * It deliberately reuses {@link computeDiffRows} from `LayeredDiff` — the same + * structural diff engine the Layers tab uses — fed `(published, draft)` so + * "added" = in the draft but not yet published, "removed" = published key the + * draft drops, "modified" = value changed. + */ +import React from 'react'; +import { computeDiffRows, type DiffStatus } from './LayeredDiff'; +import { t, type SupportedLocale } from './i18n'; + +const STATUS_BADGE: Record, string> = { + modified: + 'bg-amber-100 text-amber-900 border border-amber-300 dark:bg-amber-950/60 dark:text-amber-200 dark:border-amber-800', + added: + 'bg-emerald-100 text-emerald-900 border border-emerald-300 dark:bg-emerald-950/60 dark:text-emerald-200 dark:border-emerald-800', + removed: + 'bg-rose-100 text-rose-900 border border-rose-300 dark:bg-rose-950/60 dark:text-rose-200 dark:border-rose-800', +}; + +const STATUS_ROW: Record, string> = { + modified: 'bg-amber-50/50 dark:bg-amber-950/20', + added: 'bg-emerald-50/50 dark:bg-emerald-950/20', + removed: 'bg-rose-50/50 dark:bg-rose-950/20', +}; + +function statusLabel(status: Exclude, locale?: SupportedLocale | string): string { + if (status === 'added') return t('designer.canvas.diffAdded', locale); + if (status === 'removed') return t('designer.canvas.diffRemoved', locale); + return t('designer.canvas.diffChanged', locale); +} + +function formatValue(v: unknown): string { + if (v === undefined) return '—'; + if (v === null) return 'null'; + if (typeof v === 'string') return v.length > 200 ? `${v.slice(0, 200)}…` : v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + try { + const s = JSON.stringify(v); + return s.length > 200 ? `${s.slice(0, 200)}…` : s; + } catch { + return String(v); + } +} + +/** Number of top-level keys that differ between the draft and the published value. */ +export function computeDraftChangeCount(published: unknown, draft: unknown): number { + let n = 0; + for (const row of computeDiffRows(published, draft)) { + if (row.status !== 'unchanged') n += 1; + } + return n; +} + +export interface DraftReviewPanelProps { + /** The last-published (effective) value — the diff baseline. */ + published: unknown; + /** The pending draft body being reviewed. */ + draft: unknown; + locale?: SupportedLocale | string; + className?: string; +} + +export function DraftReviewPanel({ published, draft, locale, className }: DraftReviewPanelProps) { + const rows = React.useMemo( + () => computeDiffRows(published, draft).filter((r) => r.status !== 'unchanged'), + [published, draft], + ); + + if (rows.length === 0) { + return ( +
+ {t('designer.draftReview.empty', locale)} +
+ ); + } + + return ( +
+ {rows.map((row) => { + const status = row.status as Exclude; + return ( +
+ + {statusLabel(status, locale)} + + {row.key} + {status === 'added' ? ( + + {formatValue(row.effectiveValue)} + + ) : status === 'removed' ? ( + {formatValue(row.codeValue)} + ) : ( + + {formatValue(row.codeValue)} + + + {formatValue(row.effectiveValue)} + + + )} +
+ ); + })} +
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 72fe2f2d6..55747920f 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -21,7 +21,7 @@ */ import * as React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Save, RotateCcw, @@ -31,6 +31,7 @@ import { Loader2, AlertTriangle, Layers3, + GitCompareArrows, Boxes, Eye, Pencil, @@ -79,6 +80,7 @@ import type { import { PageShell } from './PageShell'; import { MetadataTypeActions } from './MetadataTypeActions'; import { LayeredDiff, countOverlaidFields } from './LayeredDiff'; +import { DraftReviewPanel, computeDraftChangeCount } from './DraftReviewPanel'; import { SchemaForm, type SchemaFormIssue } from './SchemaForm'; import { useMetadataClient, @@ -219,6 +221,7 @@ function MetadataResourceEditPageImpl({ embedded, }: MetadataResourceEditPageImplProps) { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const client = useMetadataClient(); const { entries } = useMetadataTypes(client); const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type); @@ -561,7 +564,32 @@ function MetadataResourceEditPageImpl({ const initialTabRef = React.useRef(null); const [openSheet, setOpenSheet] = - React.useState<'layers' | 'references' | 'related' | 'history' | 'audit' | null>(null); + React.useState<'layers' | 'references' | 'related' | 'history' | 'audit' | 'review' | null>(null); + + // ADR-0033 Phase B — `?review=1` arrival (from the chat's "Review N change(s)" + // affordance). The AI may have drafted this item *after* the page mounted, so + // we first force a fresh fetch, then — once the draft is loaded — open the + // generic review/diff sheet and consume the query param (so a refresh/back + // doesn't re-trigger it). The same-item-already-open case is covered by the + // reload bump (the load effect keys off `reloadKey`, not the search string). + const reviewParam = searchParams.get('review'); + const reviewBumpedRef = React.useRef(false); + React.useEffect(() => { + if (reviewParam !== '1' || createMode) return; + if (!reviewBumpedRef.current) { + reviewBumpedRef.current = true; + setReloadKey((k) => k + 1); + return; // wait for the reload to settle before reading hasDraft + } + if (!loading) { + if (hasDraft) setOpenSheet('review'); + const next = new URLSearchParams(searchParams); + next.delete('review'); + setSearchParams(next, { replace: true }); + reviewBumpedRef.current = false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reviewParam, createMode, loading, hasDraft]); // Inspector tabs: properties form vs raw JSON source view. Source view // is for power users who need to edit fields the form doesn't expose @@ -1211,6 +1239,32 @@ function MetadataResourceEditPageImpl({ })()} )} + {!createMode && hasDraft && ( + + )} {!createMode && ( ) : null} + {tool.draftReview && tool.draftReview.items.length > 0 && onReviewDraft ? ( +
+ + {tool.draftReview.summary ? ( + + {tool.draftReview.summary} + + ) : null} +
+ ) : null} ); diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts index 15c5755bb..1a55c88f6 100644 --- a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts @@ -84,6 +84,53 @@ describe('uiMessageToChatMessage', () => { }); }); +// ADR-0033 Phase B — the chat lifts draft envelopes onto the invocation so a +// "Review N change(s)" affordance can open the designer's review/diff. +describe('draftReview detection (ADR-0033)', () => { + const toolPart = (output: unknown) => ({ + type: 'tool-create_metadata', + toolCallId: 'd1', + state: 'output-available' as const, + input: {}, + output, + }); + const draftReviewOf = (output: unknown) => + uiMessageToChatMessage({ id: 'm', role: 'assistant', parts: [toolPart(output)] }) + .toolInvocations?.[0]?.draftReview; + + it('lifts a single drafted item ({status:drafted, type, name})', () => { + const dr = draftReviewOf({ status: 'drafted', type: 'object', name: 'project', summary: 'Drafted new object "project"' }); + expect(dr).toEqual({ items: [{ type: 'object', name: 'project' }], summary: 'Drafted new object "project"' }); + }); + + it('lifts a batch from apply_blueprint ({status:drafted, drafted:[…]})', () => { + const dr = draftReviewOf({ + status: 'drafted', + drafted: [ + { type: 'object', name: 'project' }, + { type: 'view', name: 'open_tasks' }, + ], + failed: [], + summary: 'drafted 2 artifact(s)', + }); + expect(dr?.items).toEqual([ + { type: 'object', name: 'project' }, + { type: 'view', name: 'open_tasks' }, + ]); + }); + + it('parses the Vercel `{ type:text, value }` wrapper (stringified JSON)', () => { + const dr = draftReviewOf({ type: 'text', value: JSON.stringify({ status: 'drafted', type: 'view', name: 'grid_v' }) }); + expect(dr?.items).toEqual([{ type: 'view', name: 'grid_v' }]); + }); + + it('does NOT surface blueprint_proposed (no draft yet) or plain results', () => { + expect(draftReviewOf({ status: 'blueprint_proposed', counts: { objects: 3 } })).toBeUndefined(); + expect(draftReviewOf({ users: [] })).toBeUndefined(); + expect(draftReviewOf({ status: 'drafted' })).toBeUndefined(); // no type/name → no target + }); +}); + 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 1d3d65367..407abb2d5 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -67,9 +67,13 @@ function extractReasoning(parts: AnyPart[]): string | undefined { * is enabled. We surface this on the invocation so chat UIs can render an * inline approve/reject affordance without round-tripping back to the server. */ -function detectPendingApproval( - result: unknown, -): { pendingActionId: string; raw: Record } | undefined { +/** + * Parse a tool result into the framework's JSON envelope object, if it is one. + * The Vercel SDK wraps tool outputs as `{ type: 'text', value: string }`, so we + * peel one layer if present, then fall back to the raw value. Returns the first + * candidate that parses to a plain object. + */ +function parseResultEnvelope(result: unknown): Record | undefined { const tryParse = (value: unknown): Record | undefined => { if (value && typeof value === 'object') return value as Record; if (typeof value !== 'string') return undefined; @@ -82,24 +86,66 @@ function detectPendingApproval( return undefined; } }; - // The Vercel SDK wraps tool outputs as `{ type: 'text', value: string }`. - // Peel one layer if present, then fall back to the raw value. const candidates: unknown[] = [result]; if (result && typeof result === 'object' && 'value' in (result as Record)) { candidates.push((result as Record).value); } + // Prefer a candidate that carries the framework's `status` discriminator — + // this peels the Vercel `{ type:'text', value:'…json…' }` wrapper, whose + // outer object has no `status`, to the inner envelope that does. + let fallback: Record | undefined; for (const candidate of candidates) { const obj = tryParse(candidate); if (!obj) continue; - const status = obj.status; - const id = obj.pendingActionId; - if (status === 'pending_approval' && typeof id === 'string' && id.length > 0) { - return { pendingActionId: id, raw: obj }; - } + if (typeof obj.status === 'string') return obj; + fallback ??= obj; + } + return fallback; +} + +function detectPendingApproval( + result: unknown, +): { pendingActionId: string; raw: Record } | undefined { + const obj = parseResultEnvelope(result); + if (!obj) return undefined; + const id = obj.pendingActionId; + if (obj.status === 'pending_approval' && typeof id === 'string' && id.length > 0) { + return { pendingActionId: id, raw: obj }; } return undefined; } +/** + * Best-effort detector for the framework's ADR-0033 draft envelopes. Metadata + * authoring tools stage changes as DRAFTS and return one of: + * • single — `{ status:'drafted', type, name, summary, changedKeys }` + * (create_object / add_field / create_metadata / update_metadata / …) + * • batch — `{ status:'drafted', drafted:[{type,name}], failed, summary }` + * (apply_blueprint) + * We lift the reviewable `{ type, name }` targets so the chat can render a + * "Review N change(s)" affordance that opens the designer's review/diff. + * `blueprint_proposed` (propose_blueprint) has no draft yet → not surfaced here. + */ +function detectDraftResult( + result: unknown, +): { items: Array<{ type: string; name: string }>; summary?: string } | undefined { + const obj = parseResultEnvelope(result); + if (!obj || obj.status !== 'drafted') return undefined; + const items: Array<{ type: string; name: string }> = []; + if (Array.isArray(obj.drafted)) { + for (const d of obj.drafted) { + if (d && typeof d === 'object') { + const { type, name } = d as Record; + if (typeof type === 'string' && typeof name === 'string') items.push({ type, name }); + } + } + } else if (typeof obj.type === 'string' && typeof obj.name === 'string') { + items.push({ type: obj.type, name: obj.name }); + } + if (items.length === 0) return undefined; + return { items, summary: typeof obj.summary === 'string' ? obj.summary : undefined }; +} + function extractToolInvocations(parts: AnyPart[]): ChatToolInvocation[] { return parts .filter((p) => { @@ -115,6 +161,7 @@ function extractToolInvocations(parts: AnyPart[]): ChatToolInvocation[] { : 'tool'; const result = p.output ?? p.result; const pending = detectPendingApproval(result); + const draftReview = detectDraftResult(result); const baseState = p.state; // Promote pending HITL results to `approval-requested` so the UI // unlocks the inline approve/reject buttons. Once the operator @@ -131,6 +178,7 @@ function extractToolInvocations(parts: AnyPart[]): ChatToolInvocation[] { errorText: p.errorText, state, pendingActionId: pending?.pendingActionId, + draftReview, } satisfies ChatToolInvocation; }); }