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
11 changes: 11 additions & 0 deletions .changeset/adr-0033-draft-discoverability.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,38 @@ function PackageDetailSheet({
}) {
const [busy, setBusy] = React.useState<string | null>(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<Array<{ type: string; name: string }> | 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';
Expand Down Expand Up @@ -405,6 +431,37 @@ function PackageDetailSheet({
Browse this package's metadata
</Link>

{drafts && drafts.length > 0 && (
<>
<Separator className="my-4" />
<div className="space-y-2">
<p className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pending changes
<Badge variant="secondary">{drafts.length}</Badge>
</p>
<p className="text-xs text-muted-foreground">
Drafted, not yet published. Review and publish each to make it live.
</p>
<ul className="space-y-1">
{drafts.map((d) => (
<li key={`${d.type}/${d.name}`}>
<Link
to={`${appBase}/metadata/${encodeURIComponent(d.type)}/${encodeURIComponent(d.name)}?review=1`}
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<FileUp className="h-3.5 w-3.5" />
<span className="font-mono text-xs">{d.type}</span>
<span className="text-muted-foreground">·</span>
{d.name}
</Link>
</li>
))}
</ul>
</div>
</>
)}

<Separator className="my-4" />

{isKernel ? (
Expand Down
1 change: 1 addition & 0 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2111,6 +2111,7 @@ export { MetadataClient } from './metadata-client';
export type {
MetadataClientConfig,
MetadataListOptions,
MetadataDraftHeader,
MetadataSaveOptions,
MetadataGetOptions,
MetadataDeleteOptions,
Expand Down
34 changes: 34 additions & 0 deletions packages/data-objectstack/src/metadata-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
38 changes: 38 additions & 0 deletions packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<MetadataDraftHeader[]> {
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)`).
Expand Down
Loading