diff --git a/.changeset/28567-new-similar-flow.md b/.changeset/28567-new-similar-flow.md
new file mode 100644
index 00000000000..06451b1866c
--- /dev/null
+++ b/.changeset/28567-new-similar-flow.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(studio): "New similar" on a flow in the Automations pillar, opening the copy where the clone door landed it
+
+Clause-②: no
+Metadata-protocol: none
+
+An admin who wants a second flow like one a package ships, for another rule, gets it without changing the package's flow (ADR-0146 D4). The flow clone door lands the copy switched off in a writable package, by default the source's paired `.custom`, so Studio can offer the gesture where the flow is designed.
+
+- **New similar in the flow's menu.** The open flow's ⋯ menu offers "New similar" on a read-only package's flow and on a writable package's flow. It asks for a new machine name and a label, both mandatory, and posts them to `POST /api/v1/automation/:name/clone`. On a read-only base the request names no package, so the door picks the pair; on a writable package it names the open package.
+- **The copy opens where the answer says.** The door's answer names the package the copy landed in. A copy in another package opens on that package's Automations pillar (`/studio//automations?surface=flow:`); a copy in the open package opens in place after the rail re-reads. Either way the server's sentence that the copy is switched off is shown as sent. An answer that names no package says so and opens nothing. Nothing records or draws what the copy was made from.
+- **Refusals and gates.** A refusal, a `409` for a taken name included, is the server's sentence, shown in the dialog, which stays open. A caller without `manage_metadata` sees the item disabled with the console's sentence for why. A read-only package's flow still offers no Delete, and the package-less scope draws no menu.
diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx
index 7577bb50f08..20acf324384 100644
--- a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.deleteFlow-27356.test.tsx
@@ -5,7 +5,7 @@
* pillar's ⋯ menu, on a writable package's open flow: an in-app confirm
* naming the flow (objectui#13333), the draft's discard and the flow's delete
* at the package (`client.reset`), then the empty state over a re-read rail.
- * A refusal keeps the flow open; a read-only package draws no menu. The
+ * A refusal keeps the flow open; a read-only package's menu has no Delete. The
* harness is `AutomationsPillar.showMeFocus-26730`'s, the server doubled at
* the metadata client.
*/
@@ -145,9 +145,13 @@ describe('Automations — Delete flow (#27356 row 19)', () => {
expect(toast.success).not.toHaveBeenCalled();
});
- it('a read-only package draws no ⋯ menu', async () => {
+ // objectstack#28567 — a read-only package's flow has a ⋯ menu now, for
+ // New similar; it offers no Delete.
+ it('a read-only package\'s menu offers no Delete', async () => {
mount(true);
expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval');
- expect(screen.queryByTestId('auto-item-more')).toBeNull();
+ fireEvent.pointerDown(await screen.findByTestId('auto-item-more'), { button: 0, ctrlKey: false });
+ expect(await screen.findByTestId('auto-item-new-similar')).toBeInTheDocument();
+ expect(screen.queryByTestId('auto-item-delete')).toBeNull();
});
});
diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.newSimilar-28567.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.newSimilar-28567.test.tsx
new file mode 100644
index 00000000000..4a80943453f
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.newSimilar-28567.test.tsx
@@ -0,0 +1,310 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#28567 (ADR-0146 D4 · Q6) — "New similar" in the Automations
+ * pillar's ⋯ menu, over the flow clone door (`POST /automation//clone`).
+ *
+ * The pins, each against a control that discriminates it:
+ * - on a read-only base the request carries NO `package` (the door picks the
+ * paired `.custom`), and the copy opens in the package the answer
+ * named, on that package's Automations pillar, with the door's sentence
+ * shown as sent; the base's menu draws no Delete;
+ * - on a writable package the request names the open package, and the copy
+ * opens in this pillar after a re-read, Delete still offered;
+ * - a `409` (name taken) is the server's sentence, verbatim, the dialog stays
+ * open on the names typed, and nothing opens;
+ * - an answer that names no package opens nothing and says so;
+ * - without `manage_metadata` the item draws disabled, and nothing is sent;
+ * - the package-less scope draws no menu (no pair, no package to name).
+ *
+ * The harness is `AutomationsPillar.deleteFlow-27356`'s: the metadata server
+ * doubled at the client, the `/automation` doors doubled at `fetch`, and the
+ * pillar mounted under the Studio's package route, keyed by package as the
+ * surface mounts it, so a navigation to another package remounts it.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom';
+
+const BASE = 'com.acme.crm';
+const PAIR = 'com.acme.crm.custom';
+const WRITABLE = 'com.acme.app';
+
+const NOTICE =
+ 'The copy is switched off and does not run until you switch it on, ' +
+ 'and it still uses the same subflows, actions and objects as the original.';
+const TAKEN =
+ 'A flow named "approval_copy" already exists. The automation engine keys flows by bare name, so a clone takes a NEW name.';
+
+const FLOW = {
+ name: 'approval',
+ label: 'Approval',
+ type: 'autolaunched',
+ status: 'active',
+ nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
+ edges: [{ id: 'e1', source: 'start', target: 'end' }],
+};
+
+/** The server's state: the flows each package holds, and the clone door's next answer. */
+const server = vi.hoisted(() => ({
+ flows: {} as Record>,
+ clone: null as null | { status: number; body: unknown },
+ canAuthor: true,
+}));
+
+const mockClient = vi.hoisted(() => ({
+ list: vi.fn(async (_type: string, opts?: { packageId?: string }) => (opts?.packageId ? server.flows[opts.packageId] ?? [] : [])),
+ listDrafts: vi.fn(async () => []),
+ listTypes: vi.fn(async () => ({ entries: [] })),
+ get: vi.fn(async () => null),
+ references: vi.fn(async () => []),
+ layered: vi.fn(async (_t: string, name: string) => ({
+ code: null,
+ overlay: null,
+ effective: { ...FLOW, name, label: name === 'approval' ? 'Approval' : 'Approval (copy)' },
+ lock: 'none',
+ })),
+ getDraft: vi.fn(async (type: string, name: string) => {
+ throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
+ }),
+ save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })),
+ reset: vi.fn(async () => ({})),
+}));
+
+vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
+});
+
+vi.mock('./packages-io', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, fetchPackages: vi.fn(async () => []) };
+});
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useAdapter: () => dataSource };
+});
+
+vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useCanAuthorMetadata: () => server.canAuthor,
+}));
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { AutomationsPillar } from './StudioDesignSurface';
+import { createEmptyDataSource } from './__tests__/emptyDataSource';
+
+const dataSource = createEmptyDataSource();
+
+// ── The `/automation` doors, doubled at `fetch` ───────────────────────────
+
+const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith('/clone') && server.clone) {
+ return new Response(JSON.stringify(server.clone.body), {
+ status: server.clone.status,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ // `/automation/_status`: "absent", the pillar's documented fallback.
+ return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } });
+});
+vi.stubGlobal('fetch', fetchMock);
+
+const cloneCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).endsWith('/clone'));
+const cloneBody = (i = 0) => JSON.parse(String(cloneCalls()[i][1]?.body));
+
+/** The clone door's success answer (`CloneFlowResponseSchema`). */
+function landedIn(packageId: string | undefined) {
+ return {
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ flow: { ...FLOW, name: 'approval_copy', label: 'Approval (copy)', status: 'obsolete' },
+ ...(packageId ? { packageId } : {}),
+ notice: NOTICE,
+ },
+ },
+ };
+}
+
+beforeEach(() => {
+ server.flows = { [BASE]: [{ name: 'approval', label: 'Approval' }], [WRITABLE]: [{ name: 'approval', label: 'Approval' }], [PAIR]: [] };
+ server.clone = null;
+ server.canAuthor = true;
+ fetchMock.mockClear();
+ for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
+});
+
+afterEach(cleanup);
+
+/** The pathname + search the router is on, for the navigation pins. */
+function Where() {
+ const loc = useLocation();
+ return {`${loc.pathname}${loc.search}`};
+}
+
+/** The Studio's package route, the pillar keyed by package as the surface mounts it. */
+function PackageRoute({ readOnlyIds }: { readOnlyIds: readonly string[] }) {
+ const { pkg = '' } = useParams();
+ return ;
+}
+
+function mount(packageId: string, readOnlyIds: readonly string[] = []) {
+ render(
+
+
+ >} />
+
+ ,
+ );
+}
+
+/** Open the ⋯ menu on the open flow. */
+async function openMenu(): Promise {
+ expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval');
+ fireEvent.pointerDown(await screen.findByTestId('auto-item-more'), { button: 0, ctrlKey: false });
+}
+
+/** Pick New similar: its dialog. */
+async function openNewSimilar(): Promise {
+ await openMenu();
+ fireEvent.click(await screen.findByTestId('auto-item-new-similar'));
+ return screen.findByTestId('auto-new-similar-dialog');
+}
+
+describe('Automations — New similar on a flow (#28567)', () => {
+ it('on a read-only base sends no target, and opens the copy in the package the answer named, with its sentence as sent', async () => {
+ server.clone = landedIn(PAIR);
+ mount(BASE, [BASE]);
+ await openMenu();
+ // The base offers no Delete: its flows are the package's.
+ expect(await screen.findByTestId('auto-item-new-similar')).toBeInTheDocument();
+ expect(screen.queryByTestId('auto-item-delete')).toBeNull();
+ fireEvent.click(screen.getByTestId('auto-item-new-similar'));
+ const dialog = await screen.findByTestId('auto-new-similar-dialog');
+ // Both names mandatory, on a submittable default.
+ expect(within(dialog).getByTestId('auto-new-similar-name')).toHaveValue('approval_copy');
+ expect(within(dialog).getByTestId('auto-new-similar-label')).toHaveValue('Approval (copy)');
+
+ server.flows[PAIR] = [{ name: 'approval_copy', label: 'Approval (copy)' }];
+ fireEvent.click(within(dialog).getByTestId('auto-new-similar-submit'));
+
+ await waitFor(() => expect(cloneCalls()).toHaveLength(1));
+ expect(String(cloneCalls()[0][0])).toMatch(/\/automation\/approval\/clone$/);
+ expect(cloneCalls()[0][1]?.method).toBe('POST');
+ // EXACTLY the two members: no `package`, so the door picks the pair.
+ expect(cloneBody()).toEqual({ name: 'approval_copy', label: 'Approval (copy)' });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('where')).toHaveTextContent(`/studio/${PAIR}/automations?surface=flow%3Aapproval_copy`),
+ );
+ expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval (copy)');
+ const strip = await screen.findByTestId('auto-new-similar-strip');
+ expect(strip).toHaveTextContent(`Saved the copy approval_copy in package ${PAIR}.`);
+ // The door's sentence, VERBATIM.
+ expect(screen.getByTestId('auto-new-similar-notice').textContent).toBe(NOTICE);
+ // No lineage (ADR-0126 §9): nothing names what the copy was made from.
+ expect(strip).not.toHaveTextContent(/based on|copied from|Approval"/i);
+ expect(screen.queryByTestId('auto-new-similar-dialog')).toBeNull();
+ });
+
+ it('on a writable package sends that package as the target, and opens the copy here after a re-read', async () => {
+ server.clone = landedIn(WRITABLE);
+ mount(WRITABLE);
+ await openMenu();
+ // CONTROL: a writable package's flow keeps its Delete beside New similar.
+ expect(await screen.findByTestId('auto-item-delete')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('auto-item-new-similar'));
+ const dialog = await screen.findByTestId('auto-new-similar-dialog');
+ fireEvent.change(within(dialog).getByTestId('auto-new-similar-name'), { target: { value: 'approval_escalation' } });
+ fireEvent.change(within(dialog).getByTestId('auto-new-similar-label'), { target: { value: 'Escalation approval' } });
+ server.clone = {
+ status: 200,
+ body: { success: true, data: { flow: { ...FLOW, name: 'approval_escalation', label: 'Escalation approval' }, packageId: WRITABLE, notice: NOTICE } },
+ };
+ server.flows[WRITABLE] = [...server.flows[WRITABLE], { name: 'approval_escalation', label: 'Escalation approval' }];
+ const listReads = mockClient.list.mock.calls.length;
+ fireEvent.click(within(dialog).getByTestId('auto-new-similar-submit'));
+
+ await waitFor(() => expect(cloneCalls()).toHaveLength(1));
+ expect(cloneBody()).toEqual({ name: 'approval_escalation', label: 'Escalation approval', package: WRITABLE });
+ await waitFor(() => expect(mockClient.list.mock.calls.length).toBeGreaterThan(listReads));
+ await waitFor(() => expect(mockClient.layered.mock.calls.map(([, n]) => n)).toContain('approval_escalation'));
+ expect(screen.getByTestId('where')).toHaveTextContent(`/studio/${WRITABLE}/automations`);
+ expect(await screen.findByTestId('auto-new-similar-strip')).toHaveTextContent(
+ `Saved the copy approval_escalation in package ${WRITABLE}.`,
+ );
+ expect(screen.getByTestId('auto-new-similar-notice').textContent).toBe(NOTICE);
+ });
+
+ it('shows a 409 verbatim, keeps the dialog open on the names typed, and opens nothing', async () => {
+ server.clone = { status: 409, body: { success: false, error: { code: 'RESOURCE_CONFLICT', message: TAKEN } } };
+ mount(BASE, [BASE]);
+ const dialog = await openNewSimilar();
+ fireEvent.click(within(dialog).getByTestId('auto-new-similar-submit'));
+ expect(await within(dialog).findByTestId('auto-new-similar-refusal')).toHaveTextContent(TAKEN);
+ expect(within(dialog).getByTestId('auto-new-similar-refusal').textContent).toBe(TAKEN);
+ expect(screen.getByTestId('auto-new-similar-dialog')).toBeInTheDocument();
+ expect(within(dialog).getByTestId('auto-new-similar-name')).toHaveValue('approval_copy');
+ expect(screen.getByTestId('where')).toHaveTextContent(`/studio/${BASE}/automations`);
+ expect(screen.queryByTestId('auto-new-similar-strip')).toBeNull();
+ });
+
+ it('shows any other refusal verbatim too (a 422 on the target)', async () => {
+ const sentence = 'Package com.acme.app is read-only: a flow is written into a writable package.';
+ server.clone = { status: 422, body: { success: false, error: { code: 'WRITABLE_PACKAGE_REQUIRED', message: sentence } } };
+ mount(WRITABLE);
+ const dialog = await openNewSimilar();
+ fireEvent.click(within(dialog).getByTestId('auto-new-similar-submit'));
+ expect((await within(dialog).findByTestId('auto-new-similar-refusal')).textContent).toBe(sentence);
+ });
+
+ it('an answer that names no package opens nothing and says so', async () => {
+ server.clone = landedIn(undefined);
+ mount(BASE, [BASE]);
+ const dialog = await openNewSimilar();
+ fireEvent.click(within(dialog).getByTestId('auto-new-similar-submit'));
+ const strip = await screen.findByTestId('auto-new-similar-strip');
+ expect(strip).toHaveTextContent('does not name the package it landed in, so nothing was opened');
+ expect(screen.getByTestId('auto-new-similar-notice').textContent).toBe(NOTICE);
+ expect(screen.getByTestId('where')).toHaveTextContent(`/studio/${BASE}/automations`);
+ expect(screen.getByTestId('auto-canvas-caption')).toHaveTextContent('Approval');
+ });
+
+ it('without manage_metadata the item is disabled and says why, and nothing is sent', async () => {
+ server.canAuthor = false;
+ mount(BASE, [BASE]);
+ await openMenu();
+ const item = await screen.findByTestId('auto-item-new-similar');
+ expect(item).toHaveAttribute('data-disabled');
+ expect(item).toHaveAttribute('title', expect.stringContaining('manage_metadata'));
+ fireEvent.click(item);
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByTestId('auto-new-similar-dialog')).toBeNull();
+ expect(cloneCalls()).toHaveLength(0);
+ });
+
+ it('the package-less scope draws no menu', async () => {
+ mockClient.list.mockImplementation(async () => [{ name: 'approval', label: 'Approval' }]);
+ try {
+ render(
+
+
+ ,
+ );
+ expect(await screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 })).toHaveTextContent('Approval');
+ expect(screen.queryByTestId('auto-item-more')).toBeNull();
+ } finally {
+ mockClient.list.mockImplementation(async (_type: string, opts?: { packageId?: string }) =>
+ opts?.packageId ? server.flows[opts.packageId] ?? [] : [],
+ );
+ }
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx
index 3df4523b37c..23c5dbecbb8 100644
--- a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx
@@ -1,13 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * FlowItemMoreMenu — the Automations pillar's ⋯ menu on an open flow, with
- * Delete flow (#27356 row 19, maintainer-ruled A1; Studio had no delete,
- * objectui#12725). The confirmation is Setup's: the in-app dialog naming the
- * flow by its label and `type/name` (objectui#13333). The pillar draws it only
- * for a writable package's flow and sends the delete itself. Reset overlay is
- * not offered: it arrives with the overlay editing of the Studio / Setup
- * boundary record's phase 2 (#28045).
+ * FlowItemMoreMenu — the Automations pillar's ⋯ menu on an open flow.
+ *
+ * - **New similar** (objectstack#28567, ADR-0146 D4 · Q6): the flow clone
+ * door (`POST /api/v1/automation//clone`) under a NEW machine name and
+ * label, both mandatory (ADR-0126 §7.1). Offered on a read-only package's
+ * flow and on a writable package's flow alike. The pillar names the target:
+ * none on a read-only base, so the door picks the paired `.custom`;
+ * the open package on a writable one. The door's answer names where the copy
+ * landed (`data.packageId`) and carries its sentence that the copy is
+ * switched off (`data.notice`); both go to the pillar as sent. A refusal (a
+ * `409` for a taken name included) is the server's sentence, verbatim, and
+ * the dialog stays open on it. No lineage is drawn (ADR-0126 §9): nothing
+ * here says what the copy was made from.
+ * - **Delete flow** (#27356 row 19, maintainer-ruled A1; Studio had no
+ * delete, objectui#12725), a writable package's flow only. The confirmation
+ * is Setup's: the in-app dialog naming the flow by its label and
+ * `type/name` (objectui#13333). The pillar sends the delete itself. Reset
+ * overlay is not offered: it arrives with the overlay editing of the
+ * Studio / Setup boundary record's phase 2 (#28045).
*/
import * as React from 'react';
@@ -20,21 +32,146 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
+ Input,
+ Label,
} from '@object-ui/components';
-import { MoreHorizontal, Trash2 } from 'lucide-react';
+import { actionErrorDetail } from '@object-ui/core';
+import { Copy, Loader2, MoreHorizontal, Trash2 } from 'lucide-react';
import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n.js';
+import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
+import { apiBase } from '../../utils/apiBase.js';
import { STUDIO_HEADER_ICON_BUTTON } from './studio-tokens.js';
-/** `label` is the flow's display label; `disabled` holds the delete while a save is in flight. */
-export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete }: {
- name: string; label: string; locale: string; disabled?: boolean; onDelete: () => void;
+/** What the clone door saved: the copy's name, the package it names, and its sentence. */
+export interface FlowCopied {
+ name: string;
+ /** `data.packageId` as sent; `null` when the answer named none. */
+ packageId: string | null;
+ /** `data.notice`, verbatim (the copy is switched off); `''` when none was sent. */
+ notice: string;
+}
+
+/** The clone door's answer: the copy, or its refusal verbatim. */
+type FlowNewSimilarOutcome = { ok: true; copy: FlowCopied } | { ok: false; message: string };
+
+/**
+ * `POST /automation//clone` with EXACTLY the members the clone body
+ * declares (`CloneFlowRequestSchema`, closed): `name`, `label`, and `package`
+ * only when the pillar names a target. Never throws: a refusal comes back as
+ * the server's sentence, read by `actionErrorDetail` like the toggle door's.
+ */
+async function requestFlowNewSimilar(
+ source: string,
+ body: { name: string; label: string; package?: string },
+ fallbacks: { http: (status: number) => string; network: string },
+): Promise {
+ try {
+ const res = await fetch(`${apiBase()}/automation/${encodeURIComponent(source)}/clone`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body.package ? body : { name: body.name, label: body.label }),
+ });
+ const json: unknown = await res.json().catch(() => null);
+ if (!res.ok || envelopeRefused(json)) {
+ return { ok: false, message: actionErrorDetail(json, fallbacks.http(res.status)) };
+ }
+ const data = envelopeData(json);
+ const flowName = (data.flow as { name?: unknown } | undefined)?.name;
+ return {
+ ok: true,
+ copy: {
+ name: typeof flowName === 'string' && flowName ? flowName : body.name,
+ packageId: typeof data.packageId === 'string' && data.packageId !== '' ? data.packageId : null,
+ notice: typeof data.notice === 'string' ? data.notice : '',
+ },
+ };
+ } catch (e) {
+ return { ok: false, message: e instanceof Error && e.message ? e.message : fallbacks.network };
+ }
+}
+
+/** The "New similar" item's wiring, from the pillar. */
+export interface FlowNewSimilarProps {
+ /** The package the copy is sent to; `undefined` sends none (a read-only base: the door picks the pair). */
+ target?: string;
+ /** Why the item is disabled, in the console's words; `null` when it is live. */
+ blocked: string | null;
+ /** Asked before the dialog opens (a held edit the copy's opening would drop); `false` keeps it shut. */
+ confirmOpen?: () => boolean;
+ /** The door saved the copy. */
+ onCopied: (copy: FlowCopied) => void;
+}
+
+interface NewSimilarDraft {
+ name: string;
+ label: string;
+ busy: boolean;
+ refusal: string | null;
+}
+
+/**
+ * `label` is the flow's display label; `disabled` holds the menu's actions
+ * while a save is in flight. `onDelete` draws Delete flow (a writable
+ * package's flow); `newSimilar` draws New similar.
+ */
+export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newSimilar }: {
+ name: string;
+ label: string;
+ locale: string;
+ disabled?: boolean;
+ onDelete?: () => void;
+ newSimilar?: FlowNewSimilarProps;
}): React.ReactElement {
const [confirming, setConfirming] = React.useState(false);
+ const [copy, setCopy] = React.useState(null);
const vars = { type: 'flow', name, typeLabel: translateMetadataType('flow', locale), item: label || name };
+ const copyValid = !!copy && copy.name.trim() !== '' && copy.label.trim() !== '';
+
+ const openNewSimilar = () => {
+ if (newSimilar?.confirmOpen && !newSimilar.confirmOpen()) return;
+ // Opens on a submittable default, both fields editable, as Setup's clone
+ // dialog does (objectui#13344): `_copy` keeps the source's machine
+ // name shape. The server stays the judge of a taken name.
+ setCopy({
+ name: `${name}_copy`,
+ label: tFormat('engine.studio.auto.newSimilar.copyLabel', locale, { label: label || name }),
+ busy: false,
+ refusal: null,
+ });
+ };
+
+ const submit = async (draft: NewSimilarDraft) => {
+ if (!newSimilar) return;
+ setCopy({ ...draft, busy: true, refusal: null });
+ const outcome = await requestFlowNewSimilar(
+ name,
+ { name: draft.name.trim(), label: draft.label.trim(), ...(newSimilar.target ? { package: newSimilar.target } : {}) },
+ {
+ http: (status) => tFormat('engine.studio.auto.newSimilar.failedHttp', locale, { status }),
+ network: t('engine.studio.auto.newSimilar.failed', locale),
+ },
+ );
+ if (!outcome.ok) {
+ // VERBATIM, and the dialog stays open on the names the admin typed.
+ setCopy({ ...draft, busy: false, refusal: outcome.message });
+ return;
+ }
+ setCopy(null);
+ newSimilar.onCopied(outcome.copy);
+ };
+
return (
<>
@@ -49,36 +186,108 @@ export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete }: {
-
- setConfirming(true)}
- >
-
- {t('engine.studio.auto.deleteFlow', locale)}
-
+
+ {newSimilar && (
+
+
+ {t('engine.studio.auto.newSimilar.action', locale)}
+
+ )}
+ {onDelete && (
+ setConfirming(true)}
+ >
+
+ {t('engine.studio.auto.deleteFlow', locale)}
+
+ )}
-
-
-
- {tFormat('engine.edit.deleteConfirm', locale, vars)}
- {tFormat('engine.edit.deleteConfirmBody', locale, vars)}
-
-
- {t('engine.cancel', locale)}
- { setConfirming(false); onDelete(); }}
- >
- {t('engine.edit.delete', locale)}
-
-
-
-
+ {onDelete && (
+
+
+
+ {tFormat('engine.edit.deleteConfirm', locale, vars)}
+ {tFormat('engine.edit.deleteConfirmBody', locale, vars)}
+
+
+ {t('engine.cancel', locale)}
+ { setConfirming(false); onDelete(); }}
+ >
+ {t('engine.edit.delete', locale)}
+
+
+
+
+ )}
+ {newSimilar && (
+
+ )}
>
);
}
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index ec2be499d9f..2f38c57d5bc 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -16,7 +16,7 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
-import { useParams, useNavigate, useSearchParams, Link, Navigate } from 'react-router-dom';
+import { useParams, useNavigate, useSearchParams, useLocation, Link, Navigate } from 'react-router-dom';
import { useAdapter, SchemaRendererProvider, useMetadata } from '@object-ui/react';
// The ONE draft-envelope reader (objectui#8181): unwrap AND strip the
// framework's read decorations in one place. This file used to carry its own
@@ -79,7 +79,7 @@ import {
type StudioPendingDrafts,
} from './studioItemDraftStatus.js';
import { packagedAutomationPath } from './BuilderLanding.js';
-import { FlowItemMoreMenu } from './FlowItemMoreMenu.js';
+import { FlowItemMoreMenu, type FlowCopied } from './FlowItemMoreMenu.js';
import { FlowLedgerSwitch } from './FlowLedgerSwitch.js';
import { requestFlowToggle, useFlowLedgerWalledReadOnly } from '../setup/packagedFlowLedger.js';
import { activationReading } from '../setup/packagedFlows.js';
@@ -283,7 +283,8 @@ import {
isStudioOrgScope,
studioOrgScopePath,
} from './studioScope.js';
-import { STUDIO_METADATA_AREA, studioMetadataAreaPath } from './metadataArea.js';
+import { STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js';
+import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
import { StudioMetadataArea } from './StudioMetadataArea.js';
import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js';
import { useHomePath } from '../../hooks/useHomePath.js';
@@ -8729,6 +8730,32 @@ function withStartTrigger(
/** No navigation order: one shared empty list, so the rail's memo holds across renders. */
const NO_OBJECT_ORDER: readonly string[] = [];
+/**
+ * objectstack#28567 — what the Automations pillar's "New similar" strip says:
+ * the copy the clone door saved, shown on that copy in the package the answer
+ * named, or an answer that named no package (nothing opened). `notice` is the
+ * door's sentence, shown as sent.
+ */
+type FlowCopiedStrip =
+ | { kind: 'landed'; name: string; packageId: string; notice: string }
+ | { kind: 'noPackage'; name: string; notice: string };
+
+/** The navigation-state key the copy's landing rides to the copy's pillar on. */
+const FLOW_COPIED_ARRIVAL_STATE = 'flowNewSimilar';
+
+/**
+ * The landing a "New similar" navigation carried, kept only when it names
+ * THIS package, so a stale history entry of another package shows nothing.
+ */
+function readFlowCopiedArrival(state: unknown, packageId: string | null): FlowCopiedStrip | null {
+ const arrival = (state as Record | null | undefined)?.[FLOW_COPIED_ARRIVAL_STATE] as
+ | Partial>
+ | undefined;
+ if (!arrival || arrival.kind !== 'landed' || packageId === null || arrival.packageId !== packageId) return null;
+ if (typeof arrival.name !== 'string' || arrival.name === '') return null;
+ return { kind: 'landed', name: arrival.name, packageId, notice: typeof arrival.notice === 'string' ? arrival.notice : '' };
+}
+
export function AutomationsPillar({
packageId,
objectOrder = NO_OBJECT_ORDER,
@@ -8760,6 +8787,18 @@ export function AutomationsPillar({
const client = useMetadataClient();
const locale = useMetadataLocale();
const navigate = useNavigate();
+ const location = useLocation();
+ // objectstack#28567 — "New similar" (`FlowItemMoreMenu`): the strip that
+ // shows the copy the clone door saved, with the door's sentence that it is
+ // switched off, as sent. The copy usually opens in ANOTHER package (a
+ // read-only base's pair), which remounts this pillar (keyed by package), so
+ // the landing rides the navigation's state and is read once here.
+ const [flowCopied, setFlowCopied] = React.useState(() =>
+ readFlowCopiedArrival(location.state, packageId),
+ );
+ // The copy to open once the rail re-reads, when it landed in THIS package.
+ const openAfterListRef = React.useRef(null);
+ const canAuthorFlows = useCanAuthorMetadata();
// The draft writes' package binding (objectui#11553): none in the
// package-less scope, so the draft row stays as package-less as the flow it
// edits. The client sends no `package=` for an absent id.
@@ -8989,6 +9028,14 @@ export function AutomationsPillar({
}
const keepEmpty = keepEmptyAfterDeleteRef.current;
keepEmptyAfterDeleteRef.current = false;
+ // objectstack#28567 — a copy "New similar" saved in this package opens.
+ const copied = openAfterListRef.current;
+ openAfterListRef.current = null;
+ const copiedFlow = copied ? items.find((f) => f.name === copied) : undefined;
+ if (copiedFlow) {
+ setCurrent(copiedFlow);
+ return;
+ }
setCurrent((c) => (keepEmpty ? c : (c ?? deepLinked ?? (named ? null : items[0]) ?? null)));
} catch (e) {
if (!cancelled) setError(plainRefusal(e));
@@ -9270,6 +9317,35 @@ export function AutomationsPillar({
onDraftSaved?.({ type: 'flow', name: flow.name });
toast.success(tFormat('engine.studio.auto.flowDeleted', locale, { item: flow.label || flow.name }));
}, [client, current, packageId, hasDraft, onDraftSaved, locale]);
+ // objectstack#28567 (ADR-0146 D4) — "New similar" saved a copy. It opens
+ // only where the door's answer says it landed: in this pillar after a
+ // re-read when that is this package, else that package's Automations pillar
+ // on the copy (a read-only base's pair, `.custom`). An answer naming
+ // no package opens nothing and says so; the landing is never derived here.
+ const onFlowCopied = React.useCallback(
+ (copy: FlowCopied) => {
+ setError(null);
+ if (copy.packageId === null) {
+ setFlowCopied({ kind: 'noPackage', name: copy.name, notice: copy.notice });
+ return;
+ }
+ const landed: FlowCopiedStrip = { kind: 'landed', name: copy.name, packageId: copy.packageId, notice: copy.notice };
+ onDraftSaved?.({ type: 'flow', name: copy.name });
+ if (copy.packageId === packageId) {
+ setFlowCopied(landed);
+ openAfterListRef.current = copy.name;
+ setFlowListNonce((n) => n + 1);
+ return;
+ }
+ navigate(studioPillarItemPath(copy.packageId, 'automations', { type: 'flow', name: copy.name }), {
+ state: { [FLOW_COPIED_ARRIVAL_STATE]: landed },
+ });
+ },
+ [packageId, navigate, onDraftSaved],
+ );
+ // A landing is shown on its copy; an answer that names no package, on any flow.
+ const flowCopiedShown =
+ flowCopied && (flowCopied.kind === 'noPackage' || flowCopied.name === current?.name) ? flowCopied : null;
// ADR-0146 D4 — a read-only package's open flow offers the activation-ledger
// switch (`FlowLedgerSwitch`). It reads the engine's `_status` row for the
// flow, never the draft's `status`, in `activationReading`'s three readings,
@@ -9782,13 +9858,26 @@ export function AutomationsPillar({
/>
)}
{!flowTabsEl && autoReadOnlyLinkEl}
- {current && flowLoaded && !readOnly && packageId !== null && (
+ {/* objectstack#28567 — New similar on a read-only package's
+ flow and on a writable one's (the door picks the pair for
+ a read-only base; a writable package is the target);
+ Delete on a writable package's flow only. The
+ package-less scope has no pair and no package to name. */}
+ {current && flowLoaded && packageId !== null && (
void deleteOpenFlow()}
+ onDelete={readOnly ? undefined : () => void deleteOpenFlow()}
+ newSimilar={{
+ target: readOnly ? undefined : packageId,
+ blocked: canAuthorFlows ? null : t('engine.edit.capabilityReadOnly', locale),
+ // The copy opens in place of this flow, which drops a
+ // held edit; asked before the dialog, as New is.
+ confirmOpen: () => confirmDropUnsavedWork(flowHeld !== null, locale),
+ onCopied: onFlowCopied,
+ }}
/>
)}
>
@@ -9799,6 +9888,35 @@ export function AutomationsPillar({
no padded band and no bordered card around the canvas. A refusal
or a held edit sits above it, inside the column's gutter. */}
+ {flowCopiedShown && (
+
+ {/* The door's sentence that the copy is switched off, VERBATIM. */}
+ {flowCopiedShown.notice &&
{flowCopiedShown.notice}
}
+
+
+
+
+ )}
{(error || flowHeld) && (
{error && (
diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts
index 05d68eb5f3c..5382429fabd 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -2430,6 +2430,26 @@ const STUDIO_EN: Record = {
'engine.studio.auto.heading': 'Automations',
'engine.studio.auto.deleteFlow': 'Delete flow',
'engine.studio.auto.flowDeleted': 'Deleted flow "{item}"',
+ // objectstack#28567 (ADR-0146 D4) — "New similar" on a flow: the clone door
+ // copies it under a new machine name and label, switched off, into the
+ // package that holds the changes; its answer and refusals are shown as sent.
+ 'engine.studio.auto.newSimilar.action': 'New similar',
+ 'engine.studio.auto.newSimilar.hint': 'Copy this flow under a new name. The copy is switched off until you switch it on.',
+ 'engine.studio.auto.newSimilar.title': 'New similar flow',
+ 'engine.studio.auto.newSimilar.bodyPair':
+ 'Copies "{item}" under a new machine name and label. This package is read-only, so the server saves the copy in the package that holds your changes to it, switched off, and says where it landed.',
+ 'engine.studio.auto.newSimilar.bodyHere':
+ 'Copies "{item}" under a new machine name and label into this package, switched off. The copy takes the flow as it runs now: unpublished changes are not copied.',
+ 'engine.studio.auto.newSimilar.copyLabel': '{label} (copy)',
+ 'engine.studio.auto.newSimilar.name': 'New machine name',
+ 'engine.studio.auto.newSimilar.label': 'New label',
+ 'engine.studio.auto.newSimilar.submit': 'Create copy',
+ 'engine.studio.auto.newSimilar.failedHttp': 'Could not create the copy (HTTP {status}).',
+ 'engine.studio.auto.newSimilar.failed': 'Could not create the copy.',
+ 'engine.studio.auto.newSimilar.done': 'Saved the copy {name} in package {package}.',
+ 'engine.studio.auto.newSimilar.noPackage':
+ 'The server saved the copy {name}, but its answer does not name the package it landed in, so nothing was opened.',
+ 'engine.studio.auto.newSimilar.dismiss': 'Dismiss',
// ADR-0146 D4 — a read-only package's flow: the activation-ledger switch
// (this deployment only, never the definition), offered on the item.
'engine.studio.auto.ledger.on': 'On for this deployment',
diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts
index b7a315d4036..a1e1a6eff2d 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -2185,6 +2185,24 @@ const STUDIO_ZH: Record = {
'engine.studio.auto.heading': '自动化',
'engine.studio.auto.deleteFlow': '删除流程',
'engine.studio.auto.flowDeleted': '已删除流程「{item}」',
+ // objectstack#28567 (ADR-0146 D4) — 流程的「新建类似」:克隆门以新的机器名与标签复制,
+ // 副本为关闭状态,落在承载改动的软件包中;服务器的回答与拒绝原样显示。
+ 'engine.studio.auto.newSimilar.action': '新建类似',
+ 'engine.studio.auto.newSimilar.hint': '以新名称复制此流程。副本处于关闭状态,开启后才会运行。',
+ 'engine.studio.auto.newSimilar.title': '新建类似流程',
+ 'engine.studio.auto.newSimilar.bodyPair':
+ '以新的机器名和标签复制「{item}」。此软件包为只读,服务器会把副本以关闭状态保存到承载你对它所做改动的软件包中,并告诉你副本所在位置。',
+ 'engine.studio.auto.newSimilar.bodyHere':
+ '以新的机器名和标签把「{item}」复制到此软件包中,副本为关闭状态。副本取自当前运行的流程:未发布的改动不会被复制。',
+ 'engine.studio.auto.newSimilar.copyLabel': '{label}(副本)',
+ 'engine.studio.auto.newSimilar.name': '新的机器名',
+ 'engine.studio.auto.newSimilar.label': '新的标签',
+ 'engine.studio.auto.newSimilar.submit': '创建副本',
+ 'engine.studio.auto.newSimilar.failedHttp': '无法创建副本(HTTP {status})。',
+ 'engine.studio.auto.newSimilar.failed': '无法创建副本。',
+ 'engine.studio.auto.newSimilar.done': '已在软件包 {package} 中保存副本 {name}。',
+ 'engine.studio.auto.newSimilar.noPackage': '服务器已保存副本 {name},但回答中没有说明它所在的软件包,因此未打开任何内容。',
+ 'engine.studio.auto.newSimilar.dismiss': '关闭',
// ADR-0146 D4 — 只读软件包的流程:启用账本开关(仅本部署,不改定义)。
'engine.studio.auto.ledger.on': '本部署中已开启',
'engine.studio.auto.ledger.off': '本部署中已关闭',