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
12 changes: 12 additions & 0 deletions .changeset/12725-draft-marks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': minor
---

Studio marks the settings the AI copilot drafted: a Draft chip and a Source line ("Added as a draft by the AI copilot 2 min ago") on a field or a list-view setting the copilot changed, read from the platform's per-key draft provenance (objectui#12725, item 3; objectstack#24298 and #24395).

- **`@object-ui/data-objectstack` reads and writes the provenance.** The strict draft read (`getDraft`) serves `draftChanges` beside `item`: one `{ path, by, actor, at }` per changed key path. New exports beside `extractDraftBody`: `readDraftChanges(resp)` (the envelope's entries, or `null` when the read recorded none, never `[]`), `governingDraftChange(changes, path)` (the entry that answers for a key: the longest prefix), `draftChangesUnder(changes, path)`, and the `DraftChange` type, derived from `@objectstack/spec`'s `GetMetaItemResponse`. `MetadataClientSaveOptions` gains `surface?: 'studio' | 'api'`, sent once as `?surface=`, derived from the spec's `MetadataAuthoringSurface` minus `copilot` (the HTTP doors refuse it). `MetadataDeleteOptions` no longer inherits it: the delete door reads no such parameter.
- **Studio's own draft saves declare `surface: 'studio'`,** so a person's edit there is recorded as `studio`, not `api`.
- **The marks.** The Data pillar's field properties show the chip and the Source line when the field's entry, or an entry under it, is the copilot's. An `object` leaf's list-view properties show the chip on each setting the copilot changed (card fields, title, click to open, group by, columns, filter, sort), mark the elements it added to an array setting by comparing the draft's array with the published one, and end with a Source line naming them. A `studio` or `api` entry draws nothing, and so does a draft whose read recorded no provenance. To compare arrays, the list-view panel reads the published layer beside a draft when, and only when, the draft carries a copilot entry.

The new copy lives in the metadata-admin designer's own string tables (en and zh).
21 changes: 21 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3444,6 +3444,17 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.listView.kanbanCard.mode.none': "Don't open",
'engine.studio.inspector.listView.kanbanCard.groupBy': 'Group by field',
'engine.studio.inspector.listView.kanbanCard.pickField': 'Pick a field',
// objectui#12725 — the copilot's draft marks on a field or a view setting it changed.
'engine.studio.draftMark.chip': 'Draft',
'engine.studio.draftMark.sourceLabel': 'Source',
'engine.studio.draftMark.source': 'Added as a draft by the AI copilot {when}',
'engine.studio.draftMark.sourceJustNow': 'Added as a draft by the AI copilot just now',
'engine.studio.draftMark.sourceItems': '{items}: {count} added as a draft by the AI copilot {when}',
'engine.studio.draftMark.sourceItemsJustNow': '{items}: {count} added as a draft by the AI copilot just now',
'engine.studio.draftMark.item': '"{label}"',
'engine.studio.draftMark.minutesAgo': '{n} min ago',
'engine.studio.draftMark.hoursAgo': '{n} h ago',
'engine.studio.draftMark.daysAgo': '{n} d ago',
// objectui#11823 (steps 2 and 3) — the Interfaces pillar's New dashboard / New report / New page.
'engine.studio.interfaces.create.menuTitle': 'Create a dashboard, a report or a page in this package',
'engine.studio.interfaces.create.dashboard': 'New dashboard',
Expand Down Expand Up @@ -6771,6 +6782,16 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.listView.kanbanCard.mode.none': '不打开',
'engine.studio.inspector.listView.kanbanCard.groupBy': '分组字段',
'engine.studio.inspector.listView.kanbanCard.pickField': '选择字段',
'engine.studio.draftMark.chip': '草稿',
'engine.studio.draftMark.sourceLabel': '来源',
'engine.studio.draftMark.source': '由 AI 副驾在 {when}作为草稿加入',
'engine.studio.draftMark.sourceJustNow': '由 AI 副驾刚刚作为草稿加入',
'engine.studio.draftMark.sourceItems': '{items} {count} 项由 AI 副驾在 {when}作为草稿加入',
'engine.studio.draftMark.sourceItemsJustNow': '{items} {count} 项由 AI 副驾刚刚作为草稿加入',
'engine.studio.draftMark.item': '「{label}」',
'engine.studio.draftMark.minutesAgo': '{n} 分钟前',
'engine.studio.draftMark.hoursAgo': '{n} 小时前',
'engine.studio.draftMark.daysAgo': '{n} 天前',
// objectui#11823 (step 2) — the Interfaces pillar's New dashboard / New report.
'engine.studio.interfaces.create.menuTitle': '在本包中新建仪表盘、报表或页面',
'engine.studio.interfaces.create.dashboard': '新建仪表盘',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12725 (item 3) — the Data pillar's field properties mark a field
* the AI copilot changed: the draft chip in the rail's header, and the source
* line 「由 AI 副驾在 N 分钟前作为草稿加入」 under the field's properties.
*
* What it reads: the object's strict draft read (`getDraft`) serves the
* pending draft's per-key provenance, `draftChanges`, beside `item`
* (objectstack#24298 / #24395). The field's key path is `["fields", NAME]`,
* and the entry that answers for it is the longest prefix.
*
* Pinned:
* - a field whose answering entry is `by: 'copilot'` shows the chip and the
* source line, with N minutes from `at`;
* - `studio` and `api` show neither (a person in Studio, any other caller);
* - CONTROL: a draft whose read recorded no `draftChanges` shows neither, and
* neither does a published object with no draft.
*
* The rail holds the REAL registered `ObjectFieldInspector`.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: {
priority: { name: 'priority', label: 'Priority', type: 'text' },
renewal_count: { name: 'renewal_count', label: 'Renewals', type: 'number' },
},
};

const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000 - 5_000).toISOString();

/** What the next draft read serves: `null` for no draft. */
let draftEnvelope: Record<string, unknown> | null = null;

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => (type === 'object' ? [{ name: 'showcase_task', label: 'Task' }] : [])),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
getDraft: vi.fn(async () => draftEnvelope),
get: vi.fn(async () => null),
withPreviewDrafts() {
return this;
},
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => dataSource };
});

import { DataPillar } from './StudioDesignSurface';
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { ObjectFieldInspector } from '../metadata-admin/inspectors/ObjectFieldInspector';
import { t, tFormat } from '../metadata-admin/i18n';

const dataSource = createEmptyDataSource();
failOnAbsorbedFetchError();

registerMetadataInspector('object', ObjectFieldInspector);

beforeEach(() => {
draftEnvelope = null;
});
afterEach(cleanup);

const L = (key: string) => t(key, 'en-US');

/** A draft read of the object, the draft body as-is, with `draftChanges` when given. */
function serveDraft(draftChanges?: unknown[]) {
draftEnvelope = {
type: 'object',
name: 'showcase_task',
item: { ...objectDef, _draft: true },
version: 'v1',
...(draftChanges ? { draftChanges } : {}),
};
}

/** Render the pillar, open Form, select the field labelled `label`, and return the open rail. */
async function openRail(label: string, name: string): Promise<HTMLElement> {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<DataPillar packageId="com.example.showcase" />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: L('engine.studio.data.tab.form') }));
const card = (await screen.findByText(label)).closest('[role="button"]') as HTMLElement;
fireEvent.click(card);
const rail = await waitFor(() => {
const el = screen.getByTestId('data-field-inspector');
expect(el).toHaveTextContent(L('engine.studio.data.fieldProps'));
return el;
});
// The shipped inspector is mounted: its API-name control carries the field's name.
expect(await within(rail).findByDisplayValue(name, undefined, { timeout: 8000 })).toBeInTheDocument();
return rail;
}

const SOURCE_2_MIN = tFormat('engine.studio.draftMark.source', 'en-US', {
when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 2 }),
});

describe('Data pillar field properties — the copilot’s draft marks (objectui#12725)', () => {
it('a field the copilot changed shows the draft chip and the source line, N minutes from `at`', async () => {
serveDraft([
{ path: [], by: 'studio', actor: 'usr_1', at: minutesAgo(30) },
{ path: ['fields', 'renewal_count'], by: 'copilot', actor: 'usr_1', at: minutesAgo(2) },
]);
const rail = await openRail('Renewals', 'renewal_count');
expect(within(rail).getByTestId('studio-draft-chip')).toHaveTextContent(L('engine.studio.draftMark.chip'));
const source = within(rail).getByTestId('studio-draft-source');
expect(source).toHaveTextContent(L('engine.studio.draftMark.sourceLabel'));
expect(source).toHaveTextContent(SOURCE_2_MIN);
});

it('a copilot change to one property of the field marks the field too (an entry under its path)', async () => {
serveDraft([{ path: ['fields', 'renewal_count', 'label'], by: 'copilot', actor: 'usr_1', at: minutesAgo(2) }]);
const rail = await openRail('Renewals', 'renewal_count');
expect(within(rail).getByTestId('studio-draft-source')).toHaveTextContent(SOURCE_2_MIN);
});

it.each(['studio', 'api'])('a field last changed through `%s` shows no copilot mark', async (by) => {
serveDraft([
{ path: ['fields', 'renewal_count'], by: 'copilot', actor: 'usr_1', at: minutesAgo(9) },
{ path: ['fields', 'priority'], by, actor: 'usr_1', at: minutesAgo(2) },
]);
const rail = await openRail('Priority', 'priority');
expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
});

it('CONTROL: a draft whose read recorded no `draftChanges` shows no mark', async () => {
serveDraft();
const rail = await openRail('Renewals', 'renewal_count');
expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
});

it('CONTROL: a published object with no pending draft shows no mark', async () => {
const rail = await openRail('Renewals', 'renewal_count');
expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('ObjectHooksPanel — no curated hook editor registered (#6846)', () =>
expect.objectContaining({ handler: 'guard_fn_v2' }),
// objectui#11773 — no draft of this hook was pending at the load: its
// first save is the first-write pin.
{ mode: 'draft', packageId: 'com.example.showcase', ifNoneMatch: '*' },
{ mode: 'draft', packageId: 'com.example.showcase', ifNoneMatch: '*', surface: 'studio' },
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export function ObjectHooksPanel({
setSaving(true);
setError(null);
try {
if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId })) === 'reloaded') {
if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId, surface: 'studio' })) === 'reloaded') {
// objectui#11773 — the author chose the saved version: the list re-read
// installs it over this buffer, which holds nothing of theirs to keep.
setDirty(false);
Expand Down Expand Up @@ -363,7 +363,7 @@ export function ObjectHooksPanel({
try {
// objectui#11773 — a create is the first-write pin: refused where the
// name already holds a draft, instead of written over it.
await client.save('hook', name, fresh, { mode: 'draft', packageId, ifNoneMatch: '*' });
await client.save('hook', name, fresh, { mode: 'draft', packageId, ifNoneMatch: '*', surface: 'studio' });
// objectui#11787 — a new hook is a new draft: the count says so.
onDraftSaved?.();
setNonce((n) => n + 1);
Expand Down
Loading
Loading