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
21 changes: 21 additions & 0 deletions .changeset/adr-0033-phase-b-draft-review.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 33 additions & 2 deletions packages/app-shell/src/assistant/assistantBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
26 changes: 25 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ConnectionState>('disconnected');
const { user, getAuthConfig } = useAuth();
Expand Down Expand Up @@ -370,6 +393,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
/>
<KeyboardShortcutsDialog />
<OnboardingWalkthrough />
<DraftReviewNavigator appName={appName} />
<ErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<RouteFader className="h-full">
Expand Down
12 changes: 11 additions & 1 deletion packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,6 +92,7 @@ function buildChatLocale(
title: `${appLabel} 智能助手`,
newChat: '开启新对话',
share: '分享对话',
reviewDraft: (n: number) => `查看 ${n} 项变更`,
suggestions,
};
}
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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 && (
<ShareDialog
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 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.
*
* ADR-0033 Phase B — the generic, type-agnostic draft↔published review/diff.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DraftReviewPanel, computeDraftChangeCount } from './DraftReviewPanel';

const published = { label: 'Accounts', object: 'account', columns: ['name'] };
// label modified, columns modified, object removed, icon added
const draft = { label: 'All Accounts', columns: ['name', 'industry'], icon: 'building' };

describe('computeDraftChangeCount', () => {
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(<DraftReviewPanel published={published} draft={draft} locale="en-US" />);
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(<DraftReviewPanel published={published} draft={{ ...published }} locale="en-US" />);
expect(screen.queryByTestId('draft-review-panel')).toBeNull();
expect(screen.getByText(/No changes vs the published version/i)).toBeTruthy();
});
});
126 changes: 126 additions & 0 deletions packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<Exclude<DiffStatus, 'unchanged'>, 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<Exclude<DiffStatus, 'unchanged'>, 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<DiffStatus, 'unchanged'>, 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 (
<div className={`p-4 text-sm text-muted-foreground ${className ?? ''}`}>
{t('designer.draftReview.empty', locale)}
</div>
);
}

return (
<div className={`flex flex-col gap-1.5 ${className ?? ''}`} data-testid="draft-review-panel">
{rows.map((row) => {
const status = row.status as Exclude<DiffStatus, 'unchanged'>;
return (
<div
key={row.key}
className={`flex flex-wrap items-baseline gap-2 rounded-md px-2.5 py-1.5 text-xs ${STATUS_ROW[status]}`}
>
<span
className={`inline-flex shrink-0 items-center rounded px-1.5 py-px text-[10px] font-medium uppercase tracking-wide ${STATUS_BADGE[status]}`}
>
{statusLabel(status, locale)}
</span>
<code className="font-mono font-medium text-foreground">{row.key}</code>
{status === 'added' ? (
<ins className="text-emerald-700 no-underline dark:text-emerald-400">
{formatValue(row.effectiveValue)}
</ins>
) : status === 'removed' ? (
<del className="text-rose-700 dark:text-rose-400">{formatValue(row.codeValue)}</del>
) : (
<span className="inline-flex items-baseline gap-1.5 text-muted-foreground">
<del className="text-rose-700 dark:text-rose-400">{formatValue(row.codeValue)}</del>
<span aria-hidden="true">→</span>
<ins className="text-emerald-700 no-underline dark:text-emerald-400">
{formatValue(row.effectiveValue)}
</ins>
</span>
)}
</div>
);
})}
</div>
);
}
Loading
Loading