From fc32e1d13a5f54f29b1f5ca3a6b731154e902f10 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:26:21 +0800 Subject: [PATCH] feat(studio): surface pending drafts on the package detail (ADR-0033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MetadataClient.listDrafts (GET /api/v1/meta/_drafts) and a "Pending changes" section in the package detail sheet that lists each drafted item with a link to the existing per-item review/publish flow — so an AI-built app package no longer looks empty and the drafts are reachable. Unit tests for the client method (URL + envelope tolerance). Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0033-draft-discoverability.md | 11 ++++ .../src/views/metadata-admin/PackagesPage.tsx | 57 +++++++++++++++++++ packages/data-objectstack/src/index.ts | 1 + .../src/metadata-client.test.ts | 34 +++++++++++ .../data-objectstack/src/metadata-client.ts | 38 +++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 .changeset/adr-0033-draft-discoverability.md diff --git a/.changeset/adr-0033-draft-discoverability.md b/.changeset/adr-0033-draft-discoverability.md new file mode 100644 index 000000000..a7ea55b65 --- /dev/null +++ b/.changeset/adr-0033-draft-discoverability.md @@ -0,0 +1,11 @@ +--- +'@object-ui/data-objectstack': minor +'@object-ui/app-shell': minor +--- + +feat(studio): surface pending drafts on the package detail (ADR-0033) + +After an AI builds an app, its objects/views land as drafts bound to the app package — but Studio's active-only browsers hid them, so the package looked empty and there was no obvious way to find what to review/publish. + +- `MetadataClient.listDrafts({ packageId?, type? })` calls the new `GET /api/v1/meta/_drafts` endpoint, returning pending draft headers (with `packageId`). +- The package detail sheet (PackagesPage) now shows a **Pending changes** section listing each drafted item, each linking to the existing per-item review/diff (`?review=1`) so the user can publish it. A just-built app package is no longer shown as empty. diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 6dd741b76..58753e875 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -285,12 +285,38 @@ function PackageDetailSheet({ }) { const [busy, setBusy] = React.useState(null); const [msg, setMsg] = React.useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + // ADR-0033 — pending DRAFT items bound to this package. AI-authored metadata + // lands as drafts that the active-only browsers hide, so without this the + // package looks empty right after a build. We list them here with a link to + // the existing per-item review/diff (?review=1) so the user can publish them. + const [drafts, setDrafts] = React.useState | null>(null); React.useEffect(() => { setMsg(null); setBusy(null); }, [pkg?.manifest.id]); + React.useEffect(() => { + const pid = pkg?.manifest.id; + if (!open || !pid) { + setDrafts(null); + return; + } + let cancelled = false; + apiJson<{ drafts?: Array<{ type: string; name: string }> }>( + `/api/v1/meta/_drafts?packageId=${encodeURIComponent(pid)}`, + ) + .then((r) => { + if (!cancelled) setDrafts(r?.drafts ?? []); + }) + .catch(() => { + if (!cancelled) setDrafts([]); + }); + return () => { + cancelled = true; + }; + }, [open, pkg?.manifest.id]); + if (!pkg) return null; const id = pkg.manifest.id; const enabled = pkg.enabled !== false && pkg.status !== 'disabled'; @@ -405,6 +431,37 @@ function PackageDetailSheet({ Browse this package's metadata + {drafts && drafts.length > 0 && ( + <> + +
+

+ Pending changes + {drafts.length} +

+

+ Drafted, not yet published. Review and publish each to make it live. +

+
    + {drafts.map((d) => ( +
  • + onOpenChange(false)} + > + + {d.type} + · + {d.name} + +
  • + ))} +
+
+ + )} + {isKernel ? ( diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index c8b815072..12a324dc3 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -2111,6 +2111,7 @@ export { MetadataClient } from './metadata-client'; export type { MetadataClientConfig, MetadataListOptions, + MetadataDraftHeader, MetadataSaveOptions, MetadataGetOptions, MetadataDeleteOptions, diff --git a/packages/data-objectstack/src/metadata-client.test.ts b/packages/data-objectstack/src/metadata-client.test.ts index 22a548402..fb570dc36 100644 --- a/packages/data-objectstack/src/metadata-client.test.ts +++ b/packages/data-objectstack/src/metadata-client.test.ts @@ -121,4 +121,38 @@ describe('MetadataClient', () => { message: 'metadata_conflict', }); }); + + it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => { + const seen: string[] = []; + const c = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: mockFetch(async (url) => { + seen.push(url); + return jsonResponse({ + drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' }], + }); + }), + }); + const out = await c.listDrafts({ packageId: 'app.edu', type: 'object' }); + expect(seen[0]).toBe('http://localhost:3000/api/v1/meta/_drafts?packageId=app.edu&type=object'); + expect(out).toEqual([ + { type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' }, + ]); + }); + + it('listDrafts tolerates the {data:{drafts}} envelope and a bare array', async () => { + const enveloped = new MetadataClient({ + baseUrl: '', + fetch: mockFetch(async () => + jsonResponse({ data: { drafts: [{ type: 'view', name: 'v', packageId: null, updatedAt: null, updatedBy: null }] } })), + }); + expect((await enveloped.listDrafts()).map((d) => d.name)).toEqual(['v']); + + const bare = new MetadataClient({ + baseUrl: '', + fetch: mockFetch(async () => + jsonResponse([{ type: 'object', name: 'b', packageId: null, updatedAt: null, updatedBy: null }])), + }); + expect((await bare.listDrafts()).map((d) => d.name)).toEqual(['b']); + }); }); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index 240880276..35fcaf1d2 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -53,6 +53,19 @@ export interface MetadataListOptions { packageId?: string; } +/** + * A pending DRAFT metadata item (ADR-0033), as returned by {@link MetadataClient.listDrafts}. + * Light header — no body — carrying the owning package so the console can group + * pending changes by app package. + */ +export interface MetadataDraftHeader { + type: string; + name: string; + packageId: string | null; + updatedAt: string | null; + updatedBy: string | null; +} + export interface MetadataSaveOptions { /** * Optimistic concurrency token (the `checksum` returned by the last @@ -363,6 +376,31 @@ export class MetadataClient { return []; } + /** + * List pending DRAFT items (ADR-0033) — what an AI authored but nobody + * published yet. `list()` only sees published/active metadata, so a + * just-built app package looks empty there; this surfaces the drafts so the + * console can show a "pending changes" view and draft-aware package contents. + * Optionally narrow by `packageId` and/or `type`. Returns light headers + * (no body) carrying `packageId` for grouping. + */ + async listDrafts( + options: { packageId?: string; type?: string } = {}, + ): Promise { + const params: string[] = []; + if (options.packageId) params.push(`packageId=${encodeURIComponent(options.packageId)}`); + if (options.type) params.push(`type=${encodeURIComponent(options.type)}`); + const qs = params.length ? `?${params.join('&')}` : ''; + const url = `${this.base}/_drafts${qs}`; + const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' }); + if (!res.ok) throw await parseError(res); + const data = (await res.json()) as + | MetadataDraftHeader[] + | { drafts?: MetadataDraftHeader[]; data?: { drafts?: MetadataDraftHeader[] } }; + if (Array.isArray(data)) return data; + return data?.drafts ?? data?.data?.drafts ?? []; + } + /** * Get a single metadata item. Returns the unwrapped item content * (matching the framework REST handler which calls `res.json(item)`).