diff --git a/.changeset/12690-discard-draft-label.md b/.changeset/12690-discard-draft-label.md
new file mode 100644
index 0000000000..0ae1045117
--- /dev/null
+++ b/.changeset/12690-discard-draft-label.md
@@ -0,0 +1,11 @@
+---
+'@object-ui/app-shell': patch
+---
+
+fix(app-shell): "Discard draft" on the metadata editor names the item by its label, not its machine id (objectui#12690)
+
+Clause-②: no
+
+The confirm before a discard and the success toast after it used to name the item as `type/name` (`Draft discarded: object/uxscratch_widget`), even when the item has a label. Both now name the label that the item's loaded body carries (`Draft discarded: QA Widget`), in English and in Chinese. An item with no label still reads `type/name`, and the discard request is unchanged.
+
+The two strings live in the metadata editor's own dictionary, which the package entry does not export. Each now takes one `{item}` placeholder where it took `{type}/{name}`. The runtime draft bar (the view and report config panels) shares the confirm string. It holds no label, so it passes `type/name` as `{item}` and its text is the same as before. No prop, export or language-pack key changes.
diff --git a/packages/app-shell/src/views/RuntimeDraftBar.test.tsx b/packages/app-shell/src/views/RuntimeDraftBar.test.tsx
index a4f33f524d..6adbe4bdd7 100644
--- a/packages/app-shell/src/views/RuntimeDraftBar.test.tsx
+++ b/packages/app-shell/src/views/RuntimeDraftBar.test.tsx
@@ -130,6 +130,23 @@ describe('RuntimeDraftBar (ADR-0034)', () => {
await waitFor(() => expect(screen.getByTestId('runtime-draft-bar')).toBeTruthy());
});
+ // objectui#12690 — the confirm string this bar shares with the metadata edit
+ // page now takes one `{item}`; the bar holds no label, so it keeps naming
+ // the machine `type/name`, with no placeholder left unfilled.
+ it('the discard confirm names type/name, with every placeholder filled', async () => {
+ const confirmSpy = vi.fn(() => false);
+ vi.stubGlobal('confirm', confirmSpy);
+ const metadataClient = makeMetadataClient({ item: { columns: ['name'] } });
+
+ render();
+
+ fireEvent.click(await screen.findByTestId('runtime-draft-discard'));
+
+ expect(confirmSpy).toHaveBeenCalledWith('Discard the pending draft for view/my_view?');
+ expect(metadataClient.reset).not.toHaveBeenCalled();
+ vi.unstubAllGlobals();
+ });
+
it('stays inert until a name is known', async () => {
const metadataClient = makeMetadataClient({ item: { a: 1 } });
diff --git a/packages/app-shell/src/views/RuntimeDraftBar.tsx b/packages/app-shell/src/views/RuntimeDraftBar.tsx
index 405b57c2b7..ea21a053e5 100644
--- a/packages/app-shell/src/views/RuntimeDraftBar.tsx
+++ b/packages/app-shell/src/views/RuntimeDraftBar.tsx
@@ -166,7 +166,9 @@ export function RuntimeDraftBar({
if (!name) return;
if (
typeof confirm === 'function' &&
- !confirm(tFormat('engine.edit.discardDraftConfirm', locale, { type, name }))
+ // objectui#12690 — the shared confirm takes one `{item}`; this bar holds
+ // no label, so it names the machine `type/name` as it always has.
+ !confirm(tFormat('engine.edit.discardDraftConfirm', locale, { item: `${type}/${name}` }))
) {
return;
}
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx
new file mode 100644
index 0000000000..b5fc0e13ac
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx
@@ -0,0 +1,214 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#12690 — "Discard draft" on the metadata edit page names the item by
+ * its label, not by its machine id.
+ *
+ * Measured on the card: discarding the draft of an object labelled "QA Widget"
+ * asked `…for object/uxscratch_widget?` and then toasted
+ * `Draft discarded: object/uxscratch_widget`. The confirm and the toast now
+ * name the label the item's loaded body carries, in both catalogs the editor
+ * ships; an item that carries no label still reads `type/name`, and the discard
+ * request itself is unchanged.
+ *
+ * The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
+ * server answering as the framework does (the double
+ * `ResourceEditPage.discardFeedback-12303.test.tsx` uses). Only `sonner`,
+ * `window.confirm` and the editor's locale hook are replaced, so the two
+ * strings the page hands them are what is read.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { z } from 'zod';
+import { ComponentRegistry } from '@object-ui/core';
+import { DocSchema } from '@objectstack/spec/system';
+import { MetadataClient } from '@object-ui/data-objectstack';
+
+const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
+vi.mock('sonner', () => ({ toast: toasts }));
+
+const locale = vi.hoisted(() => ({ current: 'en-US' as 'en-US' | 'zh-CN' }));
+vi.mock('./i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useMetadataLocale: () => locale.current,
+}));
+
+const server = {
+ active: new Map>(),
+ drafts: new Map>(),
+ /** Every DELETE the page sent, as path + query. */
+ deletes: [] as string[],
+};
+
+const json = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+async function serve(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const method = (init?.method ?? 'GET').toUpperCase();
+ const url = new URL(String(input), 'http://console.test');
+ const q = url.searchParams;
+ const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
+ if (!meta) return json(200, []);
+ const seg = meta[1].split('/').map(decodeURIComponent);
+ const key = `${seg[0]}/${seg[1]}`;
+ if (seg[0] === '_drafts') {
+ return json(200, { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) });
+ }
+ if (seg.length === 1) return json(200, { items: [] });
+ if (seg.length === 3 && seg[2] === 'layers') {
+ const row = server.active.get(key);
+ return row
+ ? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none' })
+ : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
+ }
+ if (seg.length === 3 && seg[2] === 'references') return json(200, []);
+ if (seg.length === 2 && method === 'DELETE') {
+ server.deletes.push(`${url.pathname}${url.search}`);
+ if (q.get('state') === 'draft') server.drafts.delete(key);
+ else server.active.delete(key);
+ return json(200, {});
+ }
+ if (seg.length === 2) {
+ const draftRead = q.get('state') === 'draft';
+ const row = (draftRead ? server.drafts : server.active).get(key);
+ return row
+ ? json(200, { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row })
+ : json(404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } });
+ }
+ return json(200, {});
+}
+
+const client = vi.hoisted(() => ({ current: null as unknown }));
+
+vi.mock('./useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ useMetadataClient: () => client.current,
+ useMetadataTypes: () => ({
+ loading: false,
+ entries: [
+ { type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: false, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA },
+ ],
+ }),
+ };
+});
+
+import { MetadataResourceEditPage } from './ResourceEditPage';
+import { registerDefaultMetadataSchemas } from './default-schemas';
+import { registerBuiltinPreviews } from './previews';
+import { tFormat } from './i18n';
+
+const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record;
+client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
+
+registerDefaultMetadataSchemas();
+registerBuiltinPreviews();
+
+const STUB_NS = 'discard-draft-label-12690';
+function StubMarkdown({ schema }: { schema: { content?: string } }) {
+ return {schema.content}
;
+}
+beforeAll(() => {
+ ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS });
+});
+afterAll(() => {
+ ComponentRegistry.unregister('markdown', STUB_NS);
+});
+
+const confirmSpy = vi.fn((_message?: string) => true);
+beforeEach(() => {
+ server.active.clear();
+ server.drafts.clear();
+ server.deletes = [];
+ locale.current = 'en-US';
+ for (const fn of Object.values(toasts)) fn.mockClear();
+ confirmSpy.mockReset();
+ confirmSpy.mockReturnValue(true);
+ window.localStorage.setItem('metadata-admin:autosave', '0');
+ Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: confirmSpy });
+});
+afterEach(() => {
+ cleanup();
+ window.localStorage.removeItem('metadata-admin:autosave');
+});
+
+async function settle() {
+ await act(async () => {
+ for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+const source = () => screen.findByRole('textbox', { name: /Markdown source|Markdown 源码|Markdown/ }, { timeout: 8000 });
+
+/** Opens `doc/NAME` holding a draft; `label` undefined means the body carries none. */
+async function openDrafted(name: string, label: string | undefined) {
+ const body = (content: string) => ({ name, ...(label === undefined ? {} : { label }), content, group: 'reference' });
+ server.active.set(`doc/${name}`, body('# Live'));
+ server.drafts.set(`doc/${name}`, body('# Live, drafted'));
+ render(
+
+
+ } />
+
+ ,
+ );
+ expect(await source()).toHaveValue('# Live, drafted');
+ await settle();
+}
+
+async function discard(name: string) {
+ const label = locale.current === 'zh-CN' ? '丢弃草稿' : 'Discard draft';
+ fireEvent.click(screen.getAllByRole('button', { name: label })[0]!);
+ await waitFor(() => expect(server.drafts.has(`doc/${name}`)).toBe(false));
+ await settle();
+}
+
+describe('MetadataResourceEditPage — "Discard draft" names the item by its label (objectui#12690)', () => {
+ it('en: the confirm and the toast name the label, not doc/NAME', async () => {
+ await openDrafted('qa_widget', 'QA Widget');
+ await discard('qa_widget');
+
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+ const asked = confirmSpy.mock.calls[0]![0]!;
+ expect(asked).toBe(tFormat('engine.edit.discardDraftConfirm', 'en-US', { item: 'QA Widget' }));
+ expect(asked).toContain('QA Widget');
+ expect(asked).not.toContain('doc/qa_widget');
+
+ expect(toasts.success).toHaveBeenCalledTimes(1);
+ const said = toasts.success.mock.calls[0]![0] as string;
+ expect(said).toBe(tFormat('engine.edit.draftDiscarded', 'en-US', { item: 'QA Widget' }));
+ expect(said).toContain('QA Widget');
+ expect(said).not.toContain('doc/qa_widget');
+ });
+
+ it('zh-CN: the confirm and the toast name the label, not doc/NAME', async () => {
+ locale.current = 'zh-CN';
+ await openDrafted('qa_widget', 'QA Widget');
+ await discard('qa_widget');
+
+ const asked = confirmSpy.mock.calls[0]![0]!;
+ expect(asked).toBe(tFormat('engine.edit.discardDraftConfirm', 'zh-CN', { item: 'QA Widget' }));
+ expect(asked).toContain('QA Widget');
+ expect(asked).not.toContain('doc/qa_widget');
+
+ const said = toasts.success.mock.calls[0]![0] as string;
+ expect(said).toBe(tFormat('engine.edit.draftDiscarded', 'zh-CN', { item: 'QA Widget' }));
+ expect(said).toContain('QA Widget');
+ expect(said).not.toContain('doc/qa_widget');
+ });
+
+ it('CONTROL: an item with no label still reads type/name, and the discard request is unchanged', async () => {
+ await openDrafted('plain_note', undefined);
+ await discard('plain_note');
+
+ expect(confirmSpy.mock.calls[0]![0]).toBe('Discard the pending draft for doc/plain_note?');
+ expect(toasts.success).toHaveBeenCalledWith('Draft discarded: doc/plain_note');
+ // The same one DELETE of the draft row, at the same package-less address.
+ expect(server.deletes).toEqual(['/api/v1/meta/doc/plain_note?state=draft']);
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx
index 593b622f55..c8fef7a6b2 100644
--- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx
@@ -6,8 +6,11 @@
*
* Measured on the card: the `DELETE …?state=draft` answered 200, the pending
* banner went away, and nothing on the page said so. The page now raises the
- * console's shared success toast naming the item (`type/name`), once the
- * discard and its re-read have landed. A refused discard raises none: it
+ * console's shared success toast naming the item, once the discard and its
+ * re-read have landed. objectui#12690 changed WHAT names it: the item's own
+ * label when it carries one (here the fixture's label is its name), the
+ * machine `type/name` only when it carries none; that rule is pinned in
+ * `ResourceEditPage.discardDraftLabel-12690.test.tsx`. A refused discard raises none: it
* keeps the page's error line, as before.
*
* The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
@@ -173,10 +176,11 @@ describe('MetadataResourceEditPage — "Discard draft" says what it discarded (o
expect(await source()).toHaveValue('# Live');
expect(toasts.success).toHaveBeenCalledTimes(1);
expect(toasts.success).toHaveBeenCalledWith(
- tFormat('engine.edit.draftDiscarded', 'en', { type: 'doc', name: 'live_guide' }),
+ tFormat('engine.edit.draftDiscarded', 'en', { item: 'live_guide' }),
);
- // The words name the item, not just the act.
- expect(toasts.success.mock.calls[0]![0]).toContain('doc/live_guide');
+ // The words name the item, not just the act. objectui#12690 — by its label
+ // (`doc()` labels the fixture with its name), no longer as `doc/live_guide`.
+ expect(toasts.success.mock.calls[0]![0]).toContain('live_guide');
});
it('CONTROL: a declined confirm sends no DELETE and says nothing', async () => {
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
index 4e0d816db9..1c6efaabe8 100644
--- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
@@ -1925,7 +1925,13 @@ function MetadataResourceEditPageImpl({
// Discard the pending draft (`DELETE ?state=draft`). The published
// overlay is untouched; the editor reverts to showing the live body.
async function doDiscardDraft() {
- if (!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { type, name: name ?? '' }))) {
+ // objectui#12690 — the confirm and the toast name the item by the label
+ // its loaded body carries (the one the assistant context above reads),
+ // and by the machine `type/name` only when it carries none. Read once,
+ // before the discard swaps the body.
+ const ownLabel = typeof draft.label === 'string' ? draft.label.trim() : '';
+ const item = ownLabel || `${type}/${name ?? ''}`;
+ if (!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { item }))) {
return;
}
setSaving(true);
@@ -1954,7 +1960,7 @@ function MetadataResourceEditPageImpl({
// the console's shared toast (`sonner`, the one Studio's publish uses).
// Only here, after the DELETE and the re-read landed: a refused discard
// goes to the `catch` below and keeps showing its error instead.
- toast.success(tFormat('engine.edit.draftDiscarded', locale, { type, name: name ?? '' }));
+ toast.success(tFormat('engine.edit.draftDiscarded', locale, { item }));
} catch (err: any) {
setError(err?.message ?? String(err));
} finally {
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index 98aef7b583..eac013ca29 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -268,8 +268,8 @@ const ENGINE_STRINGS_EN: Record = {
'engine.edit.publish': 'Publish',
'engine.edit.publishBlockedDirty': 'Save first, then publish.',
'engine.edit.discardDraft': 'Discard draft',
- 'engine.edit.discardDraftConfirm': 'Discard the pending draft for {type}/{name}?',
- 'engine.edit.draftDiscarded': 'Draft discarded: {type}/{name}',
+ 'engine.edit.discardDraftConfirm': 'Discard the pending draft for {item}?',
+ 'engine.edit.draftDiscarded': 'Draft discarded: {item}',
'engine.edit.draftPending': 'Pending changes — save lives in a draft. Click Publish to release.',
'engine.edit.rollback': 'Rollback to this version',
'engine.edit.rollbackConfirm': 'Restore version {version}? This writes the historical body back as the current published overlay.',
@@ -3815,8 +3815,8 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.edit.publish': '发布',
'engine.edit.publishBlockedDirty': '请先保存再发布。',
'engine.edit.discardDraft': '丢弃草稿',
- 'engine.edit.discardDraftConfirm': '丢弃 {type}/{name} 的待发布草稿?',
- 'engine.edit.draftDiscarded': '已丢弃 {type}/{name} 的待发布草稿',
+ 'engine.edit.discardDraftConfirm': '丢弃 {item} 的待发布草稿?',
+ 'engine.edit.draftDiscarded': '已丢弃 {item} 的待发布草稿',
'engine.edit.draftPending': '存在未发布的草稿 — 保存仅写入草稿,点击"发布"使其生效。',
'engine.edit.rollback': '回滚到此版本',
'engine.edit.rollbackConfirm': '恢复版本 {version}?该历史内容将被写回作为当前已发布的覆盖层。',