From 8584000cac93dd960cf18f91dc124e9aaabab8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:01:24 +0000 Subject: [PATCH 1/3] feat(app-shell): add an action to an installed app object into its customization package On a read-only base with a pair, the Data pillar Actions view offers New with the same presets and editor, and saves the action as a standalone action item in the pair (objectName bound), listed after the vendor actions with the customization mark, editable and removable. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzemmgi8p8uY913FXHx --- .../studio-design/ObjectActionsPanel.tsx | 205 ++++++++-- .../studio-design/StudioDesignSurface.tsx | 13 + .../views/studio-design/pairObjectActions.ts | 355 ++++++++++++++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 1 + ui/packages/i18n/src/namespaces/studio/zh.ts | 1 + 5 files changed, 546 insertions(+), 29 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx index 2521ac03c8a..a7e1b5a9daf 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx @@ -47,10 +47,22 @@ * name no listed action holds; a rename in the editor's Name field to a name * another listed action holds is written as the first free `_N` instead, * so the draft never carries the pair and its save is never refused for it. + * + * ## On a read-only base, an action added in its pair (objectstack#30088) + * + * A read-only base's object is never written, so its actions (the vendor's) + * are listed read-only. With `pair` (ADR-0146 D3: the base has, or can get, + * its customization package, and the ensure door admits this caller) New + * offers the same presets and editor, and the action is saved as its own + * `action` item in the pair, bound to this object by `objectName` + * (`pairObjectActions.ts`). Each such action is listed after the vendor's + * with the customization mark, opens editable and has Delete; "only + * customizations" narrows the list to them. The object draft is not touched. */ import React from 'react'; -import { Ban, Zap, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react'; +import { Ban, Zap, Plus, Trash2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; +import { useDisplayLocale } from '@object-ui/i18n'; import { Popover, PopoverTrigger, PopoverContent } from '@object-ui/components'; import { getIcon } from '../../utils/getIcon.js'; import { useRegisteredMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; @@ -66,6 +78,8 @@ import { type ActionPresetFieldOpt, } from './actionPresets.js'; import { StudioEmptyState, StudioEmptyStateAction } from './StudioEmptyState.js'; +import { usePairObjectActions, type PairActionsDoor } from './pairObjectActions.js'; +import { RailCustomizationFilter, RailCustomizationMark, useRailCustomizationsOnly } from './StudioRailCustomizations.js'; interface ActionItem { name?: string; @@ -124,6 +138,7 @@ export function ObjectActionsPanel({ onBlockingIssuesChange, onCelVerdictsChange, focus, + pair = null, }: { draft: Record; onPatch: (patch: Record) => void; @@ -154,10 +169,35 @@ export function ObjectActionsPanel({ * forward-compatible config panel for the action metadata. */ actionSchema?: Record; + /** + * objectstack#30088 — on a read-only base's object, its pair: New saves the + * action as an `action` item there (see the header). Absent, every action is + * the object draft's. + */ + pair?: PairActionsDoor | null; }) { const locale = useMetadataLocale(); - const actions = React.useMemo(() => readActions(draft.actions), [draft.actions]); + // The last-saved time reads the DISPLAY locale (objectui#10232). + const displayLocale = useDisplayLocale(); const objectName = typeof draft.name === 'string' ? draft.name : ''; + // objectui#4527 — each action's blocking CEL verdict, by name (see below). + const [celErrors, setCelErrors] = React.useState>({}); + const blockingOf = React.useCallback((name: string) => celErrors[name] ?? 0, [celErrors]); + // objectstack#30088 — the pair's actions on this object, and the vendor's: + // the object's served actions less any the pair holds (a published one is + // served folded into the object too). + const added = usePairObjectActions(pair, objectName, blockingOf); + const addedNames = React.useMemo(() => new Set(added.rows.map((r) => r.name)), [added.rows]); + const actions = React.useMemo( + () => + readActions(draft.actions).filter( + (a) => !addedNames.has(String(a.name ?? '')) && (added.pairId === null || a._packageId !== added.pairId), + ), + [draft.actions, addedNames, added.pairId], + ); + // New is offered where an action can be saved: the object draft, or the pair. + const canAdd = pair ? !pair.walled : !disabled; + const [onlyAdded, toggleOnlyAdded] = useRailCustomizationsOnly(); // objectui#11861 — new actions still waiting for their target: listed and // edited here, and NOT in the draft (see "A new action waits for its target" // above). Each is kept with the object it was started on (`on`, not the @@ -168,12 +208,33 @@ export function ObjectActionsPanel({ () => held.filter((h) => h.on === objectName).map((h) => h.action), [held, objectName], ); - const listed = React.useMemo(() => [...actions, ...heldHere], [actions, heldHere]); + const addedListed = React.useMemo(() => added.rows.map((r) => ({ ...r.body, name: r.name }) as ActionItem), [added.rows]); + const allListed = React.useMemo( + // A held action is listed once: from the moment its write lands, as the pair's. + () => [...actions, ...addedListed, ...heldHere.filter((h) => !addedNames.has(String(h.name ?? '')))], + [actions, addedListed, heldHere, addedNames], + ); + // The pair's actions that carry the mark: what "only customizations" counts and keeps. + const markOf = (name: string) => + pair?.marks && added.pairId ? pair.marks.markOf({ type: 'action', name, addedIn: added.pairId }) : null; + const markedNames = new Set(added.rows.map((r) => r.name).filter((n) => markOf(n) !== null)); + const listed = onlyAdded && pair ? allListed.filter((a) => markedNames.has(String(a.name ?? ''))) : allListed; // What each held action still needs, by name: the catalogue key of the input. const heldNeeds = React.useMemo( () => new Map(heldHere.map((a) => [String(a.name ?? ''), heldInputKey(a)])), [heldHere], ); + // A row this panel may edit: the object draft's on a writable package; on a + // read-only base, the pair's own and a held new one. + const editable = (name: string | null | undefined): boolean => { + if (pair) return !pair.walled && (addedNames.has(String(name ?? '')) || heldNeeds.has(String(name ?? ''))); + return !disabled; + }; + // Every name the object's list holds, whatever the filter shows: a new or + // renamed action takes none of them. + const takenNames = React.useMemo(() => allListed.map((a) => String(a.name ?? '')), [allListed]); + // objectstack#30088 — held actions whose write into the pair is in flight. + const creatingRef = React.useRef(new Set()); // A "Show me" from another view mounts this panel already focused. const [selected, setSelected] = React.useState(() => focus?.name ?? null); // Adjusted while rendering, React's own pattern for state that follows a prop. @@ -200,6 +261,12 @@ export function ObjectActionsPanel({ const patchSelected = React.useCallback( (requested: Record) => { if (!sel) return; + // objectstack#30088 — a pair action's buffer; a rename is held there + // (an item keeps the name it is stored under), not rewritten. + if (pair && addedNames.has(String(sel.name ?? ''))) { + added.patch(String(sel.name), requested); + return; + } // A rename to a name another listed action holds takes the first free // `_N` instead (see "An action name is taken once" above). const patch = @@ -208,7 +275,7 @@ export function ObjectActionsPanel({ ...requested, name: freeActionName( requested.name, - listed.filter((a) => a !== sel).map((a) => String(a.name ?? '')), + allListed.filter((a) => a !== sel).map((a) => String(a.name ?? '')), ), } : requested; @@ -216,17 +283,31 @@ export function ObjectActionsPanel({ const next = { ...sel, ...patch }; const mine = (h: { on: string; action: ActionItem }) => h.on === objectName && h.action.name === sel.name; if (heldInputKey(next) === null) { - onPatch({ actions: [...actions, next] }); - setHeld((prev) => prev.filter((h) => !mine(h))); + if (pair) { + // Written into the pair once it needs no input; held until it + // lands, and written once while its write is in flight. + const key = String(sel.name ?? ''); + if (!creatingRef.current.has(key)) { + creatingRef.current.add(key); + void added.create(next).then((ok) => { + creatingRef.current.delete(key); + if (ok) setHeld((prev) => prev.filter((h) => !mine(h))); + }); + } + setHeld((prev) => prev.map((h) => (mine(h) ? { on: h.on, action: next } : h))); + } else { + onPatch({ actions: [...actions, next] }); + setHeld((prev) => prev.filter((h) => !mine(h))); + } } else { setHeld((prev) => prev.map((h) => (mine(h) ? { on: h.on, action: next } : h))); } - } else { + } else if (!pair) { onPatch({ actions: actions.map((a) => (a.name === sel.name ? { ...a, ...patch } : a)) }); } if (typeof patch.name === 'string' && patch.name !== sel.name) setSelected(patch.name); }, - [actions, heldNeeds, listed, objectName, sel, onPatch], + [actions, added, addedNames, allListed, heldNeeds, objectName, pair, sel, onPatch], ); /* ─── Blocking CEL verdicts → the Data pillar's Save gate (objectui#4527) ── @@ -238,7 +319,6 @@ export function ObjectActionsPanel({ * again. A DELETED action's inspector can never report `0`, so the total is * DERIVED against the live action-name set rather than repaired by an effect. */ - const [celErrors, setCelErrors] = React.useState>({}); const reportCel = React.useCallback((actionName: string, count: number) => { setCelErrors((prev) => (prev[actionName] === count ? prev : { ...prev, [actionName]: count })); }, []); @@ -273,7 +353,7 @@ export function ObjectActionsPanel({ // before the presets, unchanged. Its name is unique among the held actions // too. const addAction = React.useCallback(() => { - const name = nextActionName(objectName, listed.map((a) => String(a.name ?? ''))); + const name = nextActionName(objectName, takenNames); // Minimal *valid* skeleton, and a DECLARATIVE one (objectui#11820): an // "Update fields on this record" action — `operation: 'update'` with an // empty `patch` — bound to this object. It used to start as a script with a @@ -308,9 +388,10 @@ export function ObjectActionsPanel({ operation: 'update', patch: {}, }; - onPatch({ actions: [...actions, fresh] }); + if (pair) void added.create(fresh); + else onPatch({ actions: [...actions, fresh] }); setSelected(name); - }, [actions, listed, objectName, onPatch, locale]); + }, [actions, added, takenNames, objectName, onPatch, locale, pair]); // objectui#11861 — what each preset would do on THIS object, read before the // author picks one so its menu row can say which field it asks about, or @@ -344,7 +425,7 @@ export function ObjectActionsPanel({ * rest: none needed ⇒ written now; one the spec needs ⇒ held until given. */ const addPreset = (plan: Extract) => { - const name = nextActionName(objectName, listed.map((a) => String(a.name ?? ''))); + const name = nextActionName(objectName, takenNames); const fresh: ActionItem = { name, label: t('engine.studio.actions.newLabel', locale), @@ -352,8 +433,10 @@ export function ObjectActionsPanel({ locations: ['record_header'], ...plan.keys, }; - if (heldInputKey(fresh) === null) onPatch({ actions: [...actions, fresh] }); - else setHeld((prev) => [...prev, { on: objectName, action: fresh }]); + if (heldInputKey(fresh) === null) { + if (pair) void added.create(fresh); + else onPatch({ actions: [...actions, fresh] }); + } else setHeld((prev) => [...prev, { on: objectName, action: fresh }]); setSelected(name); }; @@ -361,12 +444,14 @@ export function ObjectActionsPanel({ (name: string) => { if (heldNeeds.has(name)) { setHeld((prev) => prev.filter((h) => !(h.on === objectName && h.action.name === name))); + } else if (pair) { + if (addedNames.has(name)) void added.remove(name); } else { onPatch({ actions: actions.filter((a) => a.name !== name) }); } setSelected(null); }, - [actions, heldNeeds, objectName, onPatch], + [actions, added, addedNames, heldNeeds, objectName, onPatch, pair], ); const headerTrigger = ( @@ -382,7 +467,7 @@ export function ObjectActionsPanel({ // objectui#12514 — the one "New" menu. While the object holds no action it // opens from the empty block's button, and once the first one lists it hands // focus to the header's "New", the trigger that is then on the page. - const menuFromEmpty = listed.length === 0; + const menuFromEmpty = allListed.length === 0; const addMenu = ( ); - if (listed.length === 0) { + // objectstack#30088 — what a read or write of the pair answered, as sent. + const pairError = added.error ? ( +
+ {added.error} +
+ ) : null; + + if (allListed.length === 0) { return ( - +
+ {added.dialog} + {pairError} + +
); } + const selAdded = sel !== null && pair !== null && addedNames.has(String(sel.name ?? '')); + const selRow = selAdded ? added.rows.find((r) => r.name === sel.name) : undefined; + const selRenamedFrom = selAdded ? added.renamedFrom(String(sel.name)) : null; + return (
+ {added.dialog} {/* action list */}
{t('engine.studio.data.tab.actions', locale)} ({listed.length}) - {!disabled && addMenu} + {canAdd && addMenu}
+ {pair && ( +
+ +
+ )}
{listed.map((a) => { const Icon = getIcon(typeof a.icon === 'string' ? a.icon : undefined); @@ -520,6 +637,13 @@ export function ObjectActionsPanel({ {type} )} + {pair && added.pairId && addedNames.has(String(a.name ?? '')) && ( + + )} ); })} @@ -546,10 +670,24 @@ export function ObjectActionsPanel({ )} {/* The list always selects an action, so this pane is never blank for want of a pick (objectui#12514 retired that sentence). */} + {pairError} {!sel ? null : Inspector ? ( <> - {!disabled && ( -
+ {editable(sel.name) && ( +
+ {/* objectstack#30088 — a pair action autosaves on its own: its status line. */} + {selAdded && added.saving ? ( + + + {t('engine.studio.autoSaving', locale)} + + ) : selAdded && added.savedAt?.name === sel.name && added.dirty !== sel.name ? ( + + {tFormat('engine.studio.data.lastSaved', locale, { + time: added.savedAt.at.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }), + })} + + ) : null}
)} + {selRenamedFrom !== null && ( +

+ {tFormat('engine.studio.actions.renameHeld', locale, { name: selRenamedFrom })} +

+ )} {/* eslint-disable-next-line react-hooks/static-components -- useRegisteredMetadataDefaultInspector returns a registered component (stable), not one created during render */} } + draft={(selRow ? selRow.body : sel) as Record} onPatch={patchSelected} - readOnly={!!disabled} + readOnly={!editable(sel.name)} locale={locale} serverSchema={actionSchema} onBlockingIssuesChange={(count) => reportCel(String(sel.name ?? ''), count)} 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 6d5f715d7a5..b2ed0095c81 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -6962,6 +6962,19 @@ export function DataPillar({ focus={actionFocus} actionSchema={typeSchemas.action} onCelVerdictsChange={reportPanelVerdicts} + // objectstack#30088 — a base object's new actions are items of the pair. + pair={ + extendsBase && customization + ? { + pairId: railPairId, + ensure: customization.ensure, + walled: customization.walled, + marks: railMarks, + publishNonce, + onDraftSaved, + } + : null + } />
) : viewMode === 'api' ? ( diff --git a/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts b/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts new file mode 100644 index 00000000000..dede02e0748 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30088 (ADR-0146 D3) — the actions a read-only base's pair adds + * to one of the base's objects, for the Data pillar's Actions view. + * + * On a writable package an action is edited on the object draft + * (`draft.actions`, saved by the object's autosave). A read-only base's object + * is never written, so an action added to it is its own `action` item in the + * base's pair (`.custom`), bound to the object by `objectName`: the item + * the metadata door already takes (`PUT /meta/action/NAME?package=PAIR&mode=draft`) + * and the pair's `publish-drafts` publishes with the rest of its drafts. + * + * This hook is that item's list and buffer: + * + * - **Read** at the pair the surface lists (or the one the ensure door + * answered): its published actions and its pending drafts, each draft read + * at the address its save writes, so the version it serves pins that save + * (objectui#11773). Only the items whose `objectName` is this object are + * kept. A draft wins over the published row of its name. + * - **Create** asks the ensure door for the pair (once per base, again only + * after a refusal) and writes the new item there as a draft, pinned + * `ifNoneMatch: '*'`. ⛔ The pair a write names is the door's answer, never + * a `.custom` spelled here. + * - **Edit** patches the row, which is the buffer; the shared autosave + * (`useDraftAutoSave`) sends it 1.5 s after the last edit, held while a CEL + * syntax error stands (`blockingOf`) or while the name differs from the one + * the item is stored under (an item is stored under its name, so a rename + * sent per keystroke would stage one more action per pause, as the hooks + * panel measured; `renamedFrom`). + * - **Remove** deletes the pair's draft, then its published row, at the pair: + * the Automations pillar's Delete flow. The base's actions are never + * written. + */ + +import * as React from 'react'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; +import { extractDraftBody, formatMetadataError } from '@object-ui/data-objectstack'; +import { useMetadataClient } from '../metadata-admin/useMetadata.js'; +import { readDraftForSave, useDraftSaveGuard, type DraftPin } from '../metadata-admin/DraftConflictDialog.js'; +import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; +import type { RailCustomizations } from './StudioRailCustomizations.js'; +import type { StudioSavedDraft } from './StudioPillarKit.js'; + +/** What the Actions view needs of a read-only base's pair, from the Data pillar. */ +export interface PairActionsDoor { + /** The base's pair for READS: listed, or the ensure door's answer; `null` while it has none. */ + pairId: string | null; + /** The ensure door: resolves the pair id every write names; rejects with its refusal. */ + ensure: () => Promise; + /** The ensure door refuses this caller: nothing is offered to add, and the pair's actions open read-only. */ + walled: boolean; + /** The base's rail marks (its served customization read): an added action carries its mark. */ + marks: RailCustomizations | null; + /** Bumped by a package publish, which drops every draft and the versions its saves received. */ + publishNonce: number; + /** A draft landed or went: the surface's pending changes read again. */ + onDraftSaved?: (saved?: StudioSavedDraft) => void; +} + +/** One action of the pair on this object. */ +export interface PairActionRow { + /** The name the item is stored under: its key, whatever the buffer's `name` says. */ + name: string; + /** The buffer: the draft's body, else the published row's, as edited since. */ + body: Record; + published: boolean; + draft: boolean; +} + +export interface PairObjectActions { + /** The pair's actions on this object, the listing's order. Empty without a door. */ + rows: readonly PairActionRow[]; + /** The pair the rows are read and written at, once known. */ + pairId: string | null; + /** A read or write of the pair failed: the server's sentence. */ + error: string | null; + /** A write is in flight. */ + saving: boolean; + /** The row whose buffer holds an unsent edit, or `null`. */ + dirty: string | null; + /** When the last draft save of a row landed. */ + savedAt: { name: string; at: Date } | null; + /** The row's stored name, while its buffer's `name` differs from it (the edit is held). */ + renamedFrom(name: string): string | null; + /** Write a new action into the pair as a draft. Resolves whether it landed. */ + create(action: Record): Promise; + /** Patch a row's buffer; the autosave sends it. */ + patch(name: string, patch: Record): void; + /** Delete the row's draft and published item at the pair. */ + remove(name: string): Promise; + /** The draft conflict dialog; render it once. */ + dialog: React.ReactElement; +} + +const NO_ROWS: readonly PairActionRow[] = []; + +/** + * The pair's actions on `objectName`. `door` null (a writable package, an + * object the pair adds, a base whose list has not answered) reads nothing and + * offers nothing; the Actions view then edits `draft.actions` as before. + * `blockingOf` is the open editor's CEL verdict for a row, by its name. + */ +export function usePairObjectActions( + door: PairActionsDoor | null, + objectName: string, + blockingOf: (name: string) => number, +): PairObjectActions { + const client = useMetadataClient(); + const active = door !== null; + const listedPair = door?.pairId ?? null; + const publishNonce = door?.publishNonce ?? 0; + // The door's answer once a write asked it: it names every later write, and + // the reads follow it until the surface lists the same pair. + const [ensured, setEnsured] = React.useState(null); + const pairId = listedPair ?? ensured; + const [rows, setRows] = React.useState<{ for: string; pair: string; list: PairActionRow[] } | null>(null); + const [error, setError] = React.useState(null); + const [saving, setSaving] = React.useState(false); + const [dirty, setDirty] = React.useState(null); + const [savedAt, setSavedAt] = React.useState<{ name: string; at: Date } | null>(null); + const [nonce, setNonce] = React.useState(0); + const reload = React.useCallback(() => setNonce((n) => n + 1), []); + const { save: saveGuarded, recordRead, forget, dialog } = useDraftSaveGuard(client, reload); + React.useEffect(() => { + forget(); + }, [publishNonce, forget]); + + // Read per render by the callbacks below, never an effect's dependency + // (ui/AGENTS.md #10): the door object is rebuilt by every render of the pillar. + const doorRef = React.useRef(door); + const dirtyRef = React.useRef(dirty); + React.useLayoutEffect(() => { + doorRef.current = door; + dirtyRef.current = dirty; + }); + + // The ensure door's answer for this base: asked by the first write, once, + // and again only after a refusal. + const askedRef = React.useRef | null>(null); + const pairForWrite = React.useCallback((): Promise => { + if (askedRef.current) return askedRef.current; + const d = doorRef.current; + if (!d) return Promise.reject(new Error('No customization package for this object.')); + const asked = d.ensure(); + askedRef.current = asked; + asked.then( + (id) => setEnsured(id), + () => { + if (askedRef.current === asked) askedRef.current = null; + }, + ); + return asked; + }, []); + + React.useEffect(() => { + if (!active || pairId === null || !objectName) return; + let cancelled = false; + const readAt = pairId; + (async () => { + try { + const [pub, draftHeaders] = await Promise.all([ + client.list('action', { packageId: readAt }) as Promise>>, + client.listDrafts({ packageId: readAt, type: 'action' }).catch(() => null), + ]); + const draftNames = (draftHeaders || []).map((d) => String(d.name ?? '')).filter(Boolean); + const reads = await Promise.all(draftNames.map((n) => readDraftForSave(client, 'action', n, readAt))); + if (cancelled) return; + const byName = new Map(); + const pins = new Map(); + for (const raw of pub || []) { + const body = stripReadDecorations(raw) as Record; + const name = typeof body.name === 'string' ? body.name : ''; + if (!name || body.objectName !== objectName) continue; + byName.set(name, { name, body, published: true, draft: false }); + // A ledger that was read and lists no draft of it: the next save creates one. + pins.set(name, draftHeaders ? { ifNoneMatch: '*' } : null); + } + draftNames.forEach((name, i) => { + const read = reads[i]!; + // A draft the server refused to read is no absence: its published + // row stays listed and pins nothing (objectstack#29148). + if ('refused' in read) { + if (byName.has(name)) pins.set(name, null); + return; + } + const body = extractDraftBody(read.answer); + if (!body || body.objectName !== objectName) return; + byName.set(name, { name, body, published: byName.has(name), draft: true }); + pins.set(name, read.pin); + }); + const open = dirtyRef.current; + setRows((prev) => { + const list = [...byName.values()]; + // A buffer holding an unsent edit is newer than what the read served. + const kept = open !== null && prev?.for === objectName ? prev.list.find((r) => r.name === open) : undefined; + return { + for: objectName, + pair: readAt, + list: kept ? list.map((r) => (r.name === kept.name ? { ...r, body: kept.body } : r)) : list, + }; + }); + for (const [name, pin] of pins) { + if (name !== open) recordRead('action', name, readAt, pin); + } + setError(null); + } catch (e) { + if (!cancelled) setError(formatMetadataError(e)); + } + })(); + return () => { + cancelled = true; + }; + }, [client, active, pairId, objectName, publishNonce, nonce, recordRead]); + + const list = active && rows !== null && rows.for === objectName && rows.pair === pairId ? rows.list : NO_ROWS; + const listRef = React.useRef(list); + React.useLayoutEffect(() => { + listRef.current = list; + }); + + const renamedFrom = React.useCallback( + (name: string): string | null => { + const row = list.find((r) => r.name === name); + return row && typeof row.body.name === 'string' && row.body.name !== name ? name : null; + }, + [list], + ); + + const patch = React.useCallback( + (name: string, change: Record) => { + if (doorRef.current?.walled) return; + setRows((prev) => + prev === null + ? prev + : { ...prev, list: prev.list.map((r) => (r.name === name ? { ...r, body: { ...r.body, ...change } } : r)) }, + ); + setDirty(name); + }, + [], + ); + + const create = React.useCallback( + async (action: Record): Promise => { + const name = String(action.name ?? ''); + if (!name) return false; + setSaving(true); + setError(null); + try { + const pair = await pairForWrite(); + // 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('action', name, action, { mode: 'draft', packageId: pair, ifNoneMatch: '*', surface: 'studio' }); + setRows((prev) => { + const row: PairActionRow = { name, body: action, published: false, draft: true }; + const base = prev !== null && prev.for === objectName && prev.pair === pair ? prev.list : listRef.current; + return { for: objectName, pair, list: [...base.filter((r) => r.name !== name), row] }; + }); + setSavedAt({ name, at: new Date() }); + doorRef.current?.onDraftSaved?.({ type: 'action', name }); + // The pair may be new: the surface lists it from here on. + reload(); + return true; + } catch (e) { + setError(formatMetadataError(e)); + return false; + } finally { + setSaving(false); + } + }, + [client, objectName, pairForWrite, reload], + ); + + const send = React.useCallback( + async (sent: DraftSend) => { + const name = dirtyRef.current; + const row = name === null ? undefined : listRef.current.find((r) => r.name === name); + if (!row) return; + setSaving(true); + setError(null); + try { + const pair = await pairForWrite(); + const outcome = await saveGuarded('action', row.name, row.body, { mode: 'draft', packageId: pair, surface: 'studio' }); + if (outcome === 'reloaded') { + setDirty(null); + return; + } + if (sent.unmoved()) setDirty(null); + setRows((prev) => + prev === null ? prev : { ...prev, list: prev.list.map((r) => (r.name === row.name ? { ...r, draft: true } : r)) }, + ); + setSavedAt({ name: row.name, at: new Date() }); + doorRef.current?.onDraftSaved?.({ type: 'action', name: row.name }); + } catch (e) { + setError(formatMetadataError(e)); + } finally { + setSaving(false); + } + }, + [pairForWrite, saveGuarded], + ); + + const dirtyRow = dirty === null ? undefined : list.find((r) => r.name === dirty); + useDraftAutoSave({ + target: `action:${dirty ?? ''}`, + loadedFor: `action:${dirtyRow ? dirtyRow.name : ''}`, + dirty: dirtyRow !== undefined, + blocked: + dirtyRow === undefined || + saving || + !!door?.walled || + blockingOf(dirtyRow.name) > 0 || + (typeof dirtyRow.body.name === 'string' && dirtyRow.body.name !== dirtyRow.name), + snapshot: dirtyRow?.body ?? null, + save: send, + }); + + const remove = React.useCallback( + async (name: string) => { + const row = listRef.current.find((r) => r.name === name); + if (!row || doorRef.current?.walled) return; + const pair = rows?.pair ?? null; + if (pair === null) return; + setSaving(true); + setError(null); + try { + if (row.draft) await client.reset('action', name, { state: 'draft', packageId: pair }); + if (row.published) await client.reset('action', name, { packageId: pair }); + } catch (e) { + setError(formatMetadataError(e)); + return; + } finally { + setSaving(false); + } + if (dirtyRef.current === name) setDirty(null); + setRows((prev) => (prev === null ? prev : { ...prev, list: prev.list.filter((r) => r.name !== name) })); + doorRef.current?.onDraftSaved?.({ type: 'action', name }); + }, + [client, rows], + ); + + return { + rows: list, + pairId: active ? (rows?.pair ?? pairId) : null, + error: active ? error : null, + saving, + dirty: dirtyRow ? dirtyRow.name : null, + savedAt, + renamedFrom, + create, + patch, + remove, + dialog, + }; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 5e50ab7b11f..d9ee463e03b 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3551,6 +3551,7 @@ const STUDIO_EN: Record = { 'engine.studio.actions.presets': 'Common actions', 'engine.studio.actions.notSaved': 'Not saved — needs “{input}”', 'engine.studio.actions.heldNeeds': 'the action “{action}” needs “{input}”', + 'engine.studio.actions.renameHeld': 'Not saved yet: an action added to an installed app keeps the name it was created with. Change the name back to {name} to save your changes.', 'engine.studio.actions.preset.change': 'Change a picklist field', 'engine.studio.actions.preset.changeHint': 'Asks for a new “{field}”, then saves it.', 'engine.studio.actions.preset.changeNeeds': 'Needs a picklist field on this object.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index b12ed8418fe..d0023c8b42a 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -3129,6 +3129,7 @@ const STUDIO_ZH: Record = { 'engine.studio.actions.presets': '常用操作', 'engine.studio.actions.notSaved': '未保存 —— 需要「{input}」', 'engine.studio.actions.heldNeeds': '操作「{action}」需要「{input}」', + 'engine.studio.actions.renameHeld': '尚未保存:给已安装应用新增的操作会保留创建时的名称。把名称改回 {name} 即可保存更改。', 'engine.studio.actions.preset.change': '修改下拉选择字段', 'engine.studio.actions.preset.changeHint': '询问新的「{field}」,然后保存。', 'engine.studio.actions.preset.changeNeeds': '需要该对象有一个下拉选择字段。', From 43d2bfb5f29f197e8f278a2ccafc6c33e1bf2bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:13:34 +0000 Subject: [PATCH 2/3] test(app-shell): pin the pair route for an installed app object action Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzemmgi8p8uY913FXHx --- .../30088-studio-pair-object-actions.md | 17 + .../DataPillar.pairActions-30088.test.tsx | 327 ++++++++++++++++++ .../studio-design/ObjectActionsPanel.tsx | 4 +- 3 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 .changeset/30088-studio-pair-object-actions.md create mode 100644 ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx diff --git a/.changeset/30088-studio-pair-object-actions.md b/.changeset/30088-studio-pair-object-actions.md new file mode 100644 index 00000000000..d0e5c2e31dc --- /dev/null +++ b/.changeset/30088-studio-pair-object-actions.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): Studio adds an action to an installed app's object, saved in the base's customization package (#30088) + +Clause-②: no +Metadata-protocol: none + +On a read-only (installed or code-shipped) package that has, or can get, its paired customization package (`.custom`), the Data pillar's Actions view of the base's object now offers New. Before, it listed the vendor's actions with no New, so an operations person could not add a button such as "Mark qualified" to an installed app's leads, although the platform accepts the result. + +- New offers the same presets and the same action editor as on a writable package. The action is saved as its own `action` item in the pair, bound to the object by `objectName`, as a draft (the first write asks the ensure door for the pair). The base's object is never written. +- The vendor's actions stay read-only in the list. The added action is listed after them with the "added" customization mark, its edits autosave into its draft in the pair, and Delete removes its draft and its published item from the pair. An added action keeps the name it was created with: a rename is held with a line saying how to save. +- The action publishes with the pair's other drafts from Pending changes, and the Actions list's "only customizations" counts it and narrows the list to the pair's actions. + +A writable package's action editing is unchanged: its actions are still written into the object draft. No spec, server or metadata-type change. diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx new file mode 100644 index 00000000000..0688a202960 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30088 (ADR-0146 D3) — Studio adds an action to a read-only + * base's object, saved as its own `action` item in the base's paired + * `.custom` package. + * + * The REAL `DataPillar`, the REAL Actions view and the REAL action editor, + * over a metadata client double. The customization the surface hands the + * pillar is built here: its `ensure` is the ensure door. + * + * Pinned: + * - the Actions view of a read-only base's object offers New, with the same + * presets, once the base has or can get its pair; a preset writes a new + * `action` item into the pair as a draft (`objectName` = the object, + * `ifNoneMatch: '*'`), asking the ensure door, and the object is not + * written; + * - the vendor's actions stay read-only (no Delete, the editor disabled); + * the added one carries the "added" mark, edits autosave into the pair's + * draft, and Delete removes its draft and its published row at the pair; + * - "only customizations" counts the added action and narrows the list to it; + * - walled: no New, and the pair's action opens read-only; + * - CONTROL: on a writable package an action is still written into the + * object draft (`actions`), and nothing is asked of the ensure door. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Module scope as well as in the `vi.mock` factory below: the cold transform is paid +// during collection, which no test or hook timeout clocks (AGENTS.md, flaky-test discipline). +import '@object-ui/components'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const BASE = 'com.example.crm'; +const PAIR = 'com.example.crm.custom'; +const OBJECT = 'crm_lead'; + +/** The vendor's action, served on the object. */ +const VENDOR = { + name: 'crm_lead_convert', + label: 'Convert', + objectName: OBJECT, + locations: ['record_header'], + type: 'script', + body: { language: 'expression', source: 'true' }, +}; + +/** An action the pair already holds on the object: published, and with a pending draft. */ +const KEPT = { + name: 'crm_lead_action_7', + label: 'Qualify', + objectName: OBJECT, + locations: ['record_more'], + operation: 'update', + patch: { status: 'qualified' }, +}; + +const srv = vi.hoisted(() => ({ + /** The pair's published actions (every object's). */ + pairPublished: [] as Array>, + /** The pair's pending action drafts, by name. */ + pairDrafts: {} as Record>, +})); + +const servedObject = () => ({ + name: OBJECT, + label: 'Lead', + fields: { + name: { type: 'text', label: 'Name' }, + status: { + type: 'select', + label: 'Status', + options: [ + { label: 'New', value: 'new' }, + { label: 'Qualified', value: 'qualified' }, + ], + }, + }, + // The published pair action is served folded into the object, under the pair. + actions: [VENDOR, ...srv.pairPublished.filter((a) => a.objectName === OBJECT).map((a) => ({ ...a, _packageId: PAIR }))], +}); + +const mockClient = { + list: vi.fn(async (type: string, opts?: { packageId?: string }) => { + if (type === 'action') return opts?.packageId === PAIR ? srv.pairPublished.map((a) => ({ ...a, _packageId: PAIR })) : []; + if (type === 'object') return opts?.packageId === PAIR ? [] : [{ name: OBJECT, label: 'Lead' }]; + return []; + }), + listDrafts: vi.fn(async (opts?: { packageId?: string; type?: string }) => + opts?.type === 'action' && opts.packageId === PAIR + ? Object.keys(srv.pairDrafts).map((name) => ({ type: 'action', name })) + : [], + ), + layered: vi.fn(async () => ({ effective: servedObject(), code: servedObject() })), + getDraft: vi.fn(async (type: string, name: string, opts?: { packageId?: string }) => + type === 'action' && opts?.packageId === PAIR && srv.pairDrafts[name] + ? { type, name, item: srv.pairDrafts[name], version: 'v1' } + : null, + ), + get: vi.fn(async () => null), + // The pair's action drafts are kept, so a read after a write serves them. + save: vi.fn(async (type: string, name: string, body: unknown, opts?: { packageId?: string }) => { + if (type === 'action' && opts?.packageId === PAIR) srv.pairDrafts[name] = body as Record; + return { ok: true, version: 'v2', state: 'draft' }; + }), + reset: vi.fn(async (type: string, name: string, opts?: { packageId?: string; state?: string }) => { + if (type === 'action' && opts?.packageId === PAIR) { + if (opts.state === 'draft') delete srv.pairDrafts[name]; + else srv.pairPublished = srv.pairPublished.filter((a) => a.name !== name); + } + return { ok: true }; + }), + withPreviewDrafts() { + return this; + }, +}; + +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 () => []) }; +}); + +// The Advanced views live in a Radix DropdownMenu; rendered as passthroughs, +// as in DataPillar.actionRefusal-26736.test.tsx. +type PassthroughProps = { children?: React.ReactNode; onSelect?: () => void }; +vi.mock('@object-ui/components', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + DropdownMenu: (p: PassthroughProps) =>
{p.children}
, + DropdownMenuTrigger: (p: PassthroughProps) =>
{p.children}
, + DropdownMenuContent: (p: PassthroughProps) =>
{p.children}
, + DropdownMenuItem: (p: PassthroughProps) => ( + + ), + DropdownMenuRadioGroup: (p: PassthroughProps) =>
{p.children}
, + DropdownMenuRadioItem: (p: PassthroughProps) => ( + + ), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +import { DataPillar, type StudioBaseCustomization } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; +import { t, tFormat } from '../metadata-admin/i18n'; +import { indexCustomizations } from './customizationMarks'; +import type { PackageCustomizationsState } from './CustomizationMark'; + +const dataSource = createEmptyDataSource(); +registerBuiltinInspectors(); + +const L = (key: string) => t(key, 'en'); +const ensure = vi.fn(async (): Promise => PAIR); + +/** The base's customization read: the pair overrides nothing. */ +function marksRead(): PackageCustomizationsState { + const read = { + packageId: BASE, + overrides: [], + extensions: [], + switchedOff: [], + contributions: [], + counts: { overrides: 0, extensions: 0, switchedOff: 0 }, + } as never; + return { status: 'read', read, index: indexCustomizations(BASE, read)!, baseName: 'CRM', contributorNames: new Map() }; +} + +function customizationOf(over: Partial = {}): StudioBaseCustomization { + return { + base: BASE, + baseLabel: 'CRM', + basedOn: '4.0.0', + walled: false, + pairId: PAIR, + ensure, + ensurePackage: vi.fn() as unknown as StudioBaseCustomization['ensurePackage'], + marks: marksRead(), + ...over, + }; +} + +beforeEach(() => { + srv.pairPublished = []; + srv.pairDrafts = {}; + ensure.mockImplementation(async () => PAIR); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function mountOnActions(props: { readOnly: boolean; customization?: StudioBaseCustomization | null }) { + render( + + + , + ); + // The first match is the flat tab (the passthrough menu draws a second). + fireEvent.click((await screen.findAllByRole('button', { name: L('engine.studio.data.tab.actions') }, { timeout: 8000 }))[0]); + await within(await screen.findByTestId('actions-list', undefined, { timeout: 8000 })).findByText('Convert'); +} + +/** Past the autosave's 1.5 s debounce. */ +async function outlastDebounce(): Promise { + await act(async () => { + await new Promise((r) => setTimeout(r, 2300)); + }); +} + +type SaveCall = [string, string, Record, Record]; +const saves = () => mockClient.save.mock.calls as unknown as SaveCall[]; +const actionSaves = () => saves().filter(([type]) => type === 'action'); +const list = () => screen.getByTestId('actions-list'); +const listRow = (label: string) => within(list()).getByText(label).closest('button') as HTMLElement; + +describe('Studio adds an action to an installed app’s object, in its customization package (objectstack#30088)', () => { + it('New writes a preset into the pair as a standalone action draft; the object is not written', async () => { + await mountOnActions({ readOnly: true, customization: customizationOf() }); + // The vendor's action: read-only, no Delete. + expect(screen.queryByRole('button', { name: L('engine.studio.actions.delete') })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: L('engine.inspector.action.label') })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: L('engine.studio.new') })); + // The same presets as a writable package's New. + for (const id of ['change_choice', 'run_flow', 'open_url', 'open_page']) { + expect(await screen.findByTestId(`action-preset-${id}`)).toBeInTheDocument(); + } + fireEvent.click(screen.getByTestId('action-preset-change_choice')); + + await waitFor(() => expect(actionSaves()).toHaveLength(1)); + const [, name, body, opts] = actionSaves()[0]!; + expect(name).toMatch(/^crm_lead_action_\d+$/); + expect(name).not.toBe(VENDOR.name); + expect(body).toMatchObject({ name, objectName: OBJECT, operation: 'update', locations: ['record_header'] }); + expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' }); + expect(ensure).toHaveBeenCalledTimes(1); + expect(saves().filter(([type]) => type === 'object')).toHaveLength(0); + + // Listed after the vendor's, with the "added" mark, open and removable. + const row = await waitFor(() => { + const r = within(list()).getAllByRole('button').find((b) => b.querySelector('[data-testid="customization-mark"]')); + expect(r).toBeTruthy(); + return r!; + }); + expect(within(row).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'added'); + expect(within(listRow('Convert')).queryByTestId('customization-mark')).toBeNull(); + expect(screen.getByRole('button', { name: L('engine.studio.actions.delete') })).toBeInTheDocument(); + }, 30_000); + + it('the pair’s action edits into its draft at the pair, and Delete removes its draft and its row there', async () => { + srv.pairPublished = [KEPT]; + srv.pairDrafts = { [KEPT.name]: KEPT }; + await mountOnActions({ readOnly: true, customization: customizationOf() }); + fireEvent.click(await within(list()).findByText('Qualify', undefined, { timeout: 8000 })); + // Served on the object AND held by the pair: listed once, as the pair's. + expect(within(list()).getAllByText('Qualify')).toHaveLength(1); + expect(within(listRow('Qualify')).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'added'); + + const label = screen.getByRole('textbox', { name: L('engine.inspector.action.label') }); + expect(label).toBeEnabled(); + fireEvent.change(label, { target: { value: 'Mark qualified' } }); + await outlastDebounce(); + await waitFor(() => expect(actionSaves()).toHaveLength(1)); + const [, name, body, opts] = actionSaves()[0]!; + expect(name).toBe(KEPT.name); + expect(body).toMatchObject({ name: KEPT.name, label: 'Mark qualified', objectName: OBJECT }); + expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR }); + expect(saves().filter(([type]) => type === 'object')).toHaveLength(0); + + fireEvent.click(screen.getByRole('button', { name: L('engine.studio.actions.delete') })); + await waitFor(() => expect(mockClient.reset).toHaveBeenCalledTimes(2)); + expect(mockClient.reset.mock.calls).toEqual([ + ['action', KEPT.name, { state: 'draft', packageId: PAIR }], + ['action', KEPT.name, { packageId: PAIR }], + ]); + await waitFor(() => expect(within(list()).queryByText('Mark qualified')).not.toBeInTheDocument()); + expect(within(list()).getByText('Convert')).toBeInTheDocument(); + }, 30_000); + + it('“only customizations” counts the added action and keeps it alone', async () => { + srv.pairPublished = [KEPT]; + await mountOnActions({ readOnly: true, customization: customizationOf() }); + const only = await screen.findByTestId('actions-customizations-only', undefined, { timeout: 8000 }); + expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en', { marked: 1 })); + fireEvent.click(only); + await waitFor(() => expect(within(list()).queryByText('Convert')).not.toBeInTheDocument()); + expect(within(list()).getByText('Qualify')).toBeInTheDocument(); + }, 30_000); + + it('walled: no New, and the pair’s action opens read-only', async () => { + srv.pairPublished = [KEPT]; + await mountOnActions({ readOnly: true, customization: customizationOf({ walled: true }) }); + expect(screen.queryByRole('button', { name: L('engine.studio.new') })).not.toBeInTheDocument(); + fireEvent.click(await within(list()).findByText('Qualify', undefined, { timeout: 8000 })); + expect(screen.getByRole('textbox', { name: L('engine.inspector.action.label') })).toBeDisabled(); + expect(screen.queryByRole('button', { name: L('engine.studio.actions.delete') })).not.toBeInTheDocument(); + expect(ensure).not.toHaveBeenCalled(); + }, 30_000); + + it('CONTROL: on a writable package the action is written into the object draft, and no door is asked', async () => { + await mountOnActions({ readOnly: false }); + fireEvent.click(screen.getByRole('button', { name: L('engine.studio.new') })); + fireEvent.click(await screen.findByTestId('action-preset-change_choice')); + await outlastDebounce(); + await waitFor(() => expect(saves().filter(([type]) => type === 'object').length).toBeGreaterThan(0)); + const [, , body] = saves().filter(([type]) => type === 'object').at(-1)!; + const names = (body.actions as Array>).map((a) => a.name); + expect(names).toContain(VENDOR.name); + expect(names.some((n) => typeof n === 'string' && /^crm_lead_action_\d+$/.test(n))).toBe(true); + expect(actionSaves()).toHaveLength(0); + expect(ensure).not.toHaveBeenCalled(); + }, 30_000); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx index a7e1b5a9daf..291ae59f07b 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx @@ -589,7 +589,7 @@ export function ObjectActionsPanel({
{added.dialog} {/* action list */} -
+
{t('engine.studio.data.tab.actions', locale)} @@ -681,7 +681,7 @@ export function ObjectActionsPanel({ {t('engine.studio.autoSaving', locale)} - ) : selAdded && added.savedAt?.name === sel.name && added.dirty !== sel.name ? ( + ) : selAdded && added.savedAt !== null && added.savedAt.name === sel.name && added.dirty !== sel.name ? ( {tFormat('engine.studio.data.lastSaved', locale, { time: added.savedAt.at.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }), From f8bdead2512aa4e8199106b679c3547af86671e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:24:58 +0000 Subject: [PATCH 3/3] fix(app-shell): never write the read decorations back into a pair action Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzemmgi8p8uY913FXHx --- .../DataPillar.pairActions-30088.test.tsx | 5 ++++- .../views/studio-design/pairObjectActions.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx index 0688a202960..be6897c84d6 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx @@ -96,7 +96,8 @@ const mockClient = { layered: vi.fn(async () => ({ effective: servedObject(), code: servedObject() })), getDraft: vi.fn(async (type: string, name: string, opts?: { packageId?: string }) => type === 'action' && opts?.packageId === PAIR && srv.pairDrafts[name] - ? { type, name, item: srv.pairDrafts[name], version: 'v1' } + ? // Served decorated, as the draft read serves it. + { type, name, item: { ...srv.pairDrafts[name], _packageId: PAIR }, version: 'v1' } : null, ), get: vi.fn(async () => null), @@ -278,6 +279,8 @@ describe('Studio adds an action to an installed app’s object, in its customiza const [, name, body, opts] = actionSaves()[0]!; expect(name).toBe(KEPT.name); expect(body).toMatchObject({ name: KEPT.name, label: 'Mark qualified', objectName: OBJECT }); + // The read's decorations are not written back. + expect(body).not.toHaveProperty('_packageId'); expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR }); expect(saves().filter(([type]) => type === 'object')).toHaveLength(0); diff --git a/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts b/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts index dede02e0748..ef59682fb0b 100644 --- a/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts +++ b/ui/packages/app-shell/src/views/studio-design/pairObjectActions.ts @@ -95,6 +95,19 @@ export interface PairObjectActions { const NO_ROWS: readonly PairActionRow[] = []; +/** + * The item's own document: `raw` without its top-level `_` keys. A served row + * (and a served draft) carries the read's decorations, `_packageId` among + * them; they are the read's, never sent back in a save. + */ +function ownBody(raw: unknown): Record { + const body: Record = {}; + for (const [key, value] of Object.entries((stripReadDecorations(raw) ?? {}) as Record)) { + if (!key.startsWith('_')) body[key] = value; + } + return body; +} + /** * The pair's actions on `objectName`. `door` null (a writable package, an * object the pair adds, a base whose list has not answered) reads nothing and @@ -169,7 +182,7 @@ export function usePairObjectActions( const byName = new Map(); const pins = new Map(); for (const raw of pub || []) { - const body = stripReadDecorations(raw) as Record; + const body = ownBody(raw); const name = typeof body.name === 'string' ? body.name : ''; if (!name || body.objectName !== objectName) continue; byName.set(name, { name, body, published: true, draft: false }); @@ -184,7 +197,8 @@ export function usePairObjectActions( if (byName.has(name)) pins.set(name, null); return; } - const body = extractDraftBody(read.answer); + const served = extractDraftBody(read.answer); + const body = served ? ownBody(served) : null; if (!body || body.objectName !== objectName) return; byName.set(name, { name, body, published: byName.has(name), draft: true }); pins.set(name, read.pin);