Skip to content
14 changes: 14 additions & 0 deletions .changeset/28239-studio-item-toolbar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(console): Studio's item header offers Setup's item tools — layers, references, history with rollback, audit and source — on every Data, Interfaces and Automations item

Clause-②: no
Metadata-protocol: none

- **Before.** An item's layers (code / overlay / effective), what references it, its version history with rollback, its audit trail and its JSON source opened only from Setup's item page. A Studio author who wanted to undo a change went to Setup to do it.
- **Now.** The open item's header in Studio's Data, Interfaces and Automations pillars carries the five as an icon group, each opening the same panel Setup's sheet holds. History and Audit are offered only to a caller the authoring-trail doors admit, as on Setup. Rollback asks the same in-app confirm, writes through the same rollback door, and the pillar re-reads the item; a read-only package shows its history without rollback. Source edits the pillar's own draft buffer, saved by its autosave, and is read-only where the item is.
- **Moved.** `LayeredDiff`, `ResourceHistoryPage` (`HistoryPanel`), `AuditPanel`, `JsonSourceEditor` and the references panel now live in `views/item-tools/`, which Setup's item page imports. `@object-ui/app-shell`'s `LayeredDiff` and `MetadataResourceHistoryPage` exports are unchanged. Setup's page behaves as before.
- **Not carried over.** Setup's autosave toggle and draft review panel: Studio keeps its own draft → publish and pending-changes review.
4 changes: 2 additions & 2 deletions ui/packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,11 @@ export { MetadataDirectoryPage } from './views/metadata-admin/DirectoryPage.js';
export { MetadataResourceRouter } from './views/metadata-admin/ResourceRouter.js';
export { MetadataResourceListPage } from './views/metadata-admin/ResourceListPage.js';
export { MetadataResourceEditPage } from './views/metadata-admin/ResourceEditPage.js';
export { MetadataResourceHistoryPage } from './views/metadata-admin/ResourceHistoryPage.js';
export { MetadataResourceHistoryPage } from './views/item-tools/ResourceHistoryPage.js';
export { MetadataDiagnosticsPage } from './views/metadata-admin/DiagnosticsPage.js';
export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js';
export { SchemaForm } from './views/metadata-admin/SchemaForm.js';
export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js';
export { LayeredDiff } from './views/item-tools/LayeredDiff.js';
// objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a
// host that routes its own legacy URLs onto the catalog (the console's
// `system/positions` / `system/permissions`). A dependency-free leaf.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import type {
MetadataClient,
} from '@object-ui/data-objectstack';
import { AuditPanel } from './AuditPanel';
import { t, translateConsoleValue } from './i18n';
import { t, translateConsoleValue } from '../metadata-admin/i18n';

afterEach(cleanup);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MetadataAuditEntry, MetadataClient } from '@object-ui/data-objectstack';
import { AuditPanel } from './AuditPanel';
import { t } from './i18n';
import { t } from '../metadata-admin/i18n';
// The designer rows this suite reads in zh live in `@object-ui/i18n`'s `studio`
// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922).
// Resident before any case, so the zh rows below read synchronously.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type {
MetadataClient,
} from '@object-ui/data-objectstack';
import { AuditPanel } from './AuditPanel';
import { translateConsoleValue } from './i18n';
import { translateConsoleValue } from '../metadata-admin/i18n';

afterEach(cleanup);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import type {
MetadataClient,
MetadataAuditEntry,
} from '@object-ui/data-objectstack';
import { t, translateConsoleValue, type SupportedLocale } from './i18n.js';
import { type LoadState, loadErrorMessage } from './loadState.js';
import { t, translateConsoleValue, type SupportedLocale } from '../metadata-admin/i18n.js';
import { type LoadState, loadErrorMessage } from '../metadata-admin/loadState.js';

export interface AuditPanelProps {
type: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
import React from 'react';
import { Skeleton } from '@object-ui/components';
import * as jsonc from 'jsonc-parser';
import { useMetadataLocale, t } from './i18n.js';
import { useMonacoFallback } from './useMonacoFallback.js';
import { useMetadataLocale, t } from '../metadata-admin/i18n.js';
import { useMonacoFallback } from '../metadata-admin/useMonacoFallback.js';

// Lazy: Monaco's React wrapper itself pulls in the editor core
// (~3MB), so we keep it out of the initial app-shell chunk.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { render, screen, cleanup } from '@testing-library/react';
import { GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api';
import type { MetadataLayered } from '@object-ui/data-objectstack';
import { LayeredDiff } from './LayeredDiff';
import { translateConsoleValue } from './i18n';
import { translateConsoleValue } from '../metadata-admin/i18n';

afterEach(cleanup);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { Badge, Switch } from '@object-ui/components';
import { cn } from '@object-ui/components';
import type { MetadataLayered } from '@object-ui/data-objectstack';
import { t, tFormat, translateConsoleValue, type SupportedLocale } from './i18n.js';
import { t, tFormat, translateConsoleValue, type SupportedLocale } from '../metadata-admin/i18n.js';

export interface LayeredDiffProps {
layered: MetadataLayered<Record<string, unknown>> | null;
Expand Down
174 changes: 174 additions & 0 deletions ui/packages/app-shell/src/views/item-tools/ReferencesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ReferencesPanel — what in the metadata graph points at one item, read by
* `client.references(type, name)`. One of the item tools Setup's item page
* and Studio's item header both open (objectstack#28239).
*/

import * as React from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Badge, Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import type { MetadataReference } from '@object-ui/data-objectstack';
import { t } from '../metadata-admin/i18n.js';
import type { useMetadataClient } from '../metadata-admin/useMetadata.js';

/**
* References panel data — one discriminated union, not a `refs` / `refsLoading`
* pair (objectui#5110).
*
* The pair could only spell two of the three facts this panel has to tell
* apart. `refs == null` was already overloaded as "still loading", so a failed
* `client.references()` call had nowhere to go: the catch wrote `setRefs([])`,
* which is byte-identical to a *successful* scan that found nothing — and the
* panel renders that state as "Nothing in the metadata graph points at this
* item. Safe to delete." A refused request, a dropped connection, an expired
* session and a `501 NOT_IMPLEMENTED` were therefore all shown to the operator
* as an affirmative, measured all-clear, with a `console.error` nobody reads as
* the only trace. The operator is on this panel precisely because they are
* about to delete something.
*
* `status` makes the third fact representable, and makes the wrong combination
* unrepresentable: no value of this type is both `error` and `loaded`, so no
* render path can read a failure as a measurement. The `error` arm carries the
* cause and asserts NOTHING about deletion safety — the honest answer when the
* question was never answered — and the panel offers a retry so the operator
* can ask it again.
*
* `idle` is deliberately distinct from `loading`: the sheet is lazy, and
* "never asked" is what re-enables a retry after a failure (see
* `useReferencesScan`'s re-entry guard).
*
* ADR-0110 D3 — a miss and a fault are different facts.
*/
export type ReferencesState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'loaded'; items: MetadataReference[] }
| { status: 'error'; message: string };

/**
* The references scan for one item, run lazily: nothing is asked until `load`
* is called (the first time the References sheet opens).
*
* Re-entry guard: a request already in flight is not duplicated, and a
* completed scan is not re-run when the sheet is reopened. `idle` and
* `error` both fall through — which is what makes the panel's Retry work
* without a second code path: it calls this same loader (objectui#5110).
*/
export function useReferencesScan(
client: ReturnType<typeof useMetadataClient>,
type: string,
name: string,
): { state: ReferencesState; load: () => Promise<void> } {
const [state, setState] = React.useState<ReferencesState>({ status: 'idle' });
async function load() {
if (state.status === 'loading' || state.status === 'loaded') return;
setState({ status: 'loading' });
try {
const r = await client.references(type, name);
setState({ status: 'loaded', items: r });
} catch (err: any) {
// NOT `{ status: 'loaded', items: [] }`: an unanswered question is not
// an answer of "nothing". The panel renders this arm as a failed check
// with a retry and says nothing about whether deleting is safe.
setState({ status: 'error', message: String(err?.message ?? err) });
}
}
return { state, load };
}

/**
* Renders exactly one arm of `ReferencesState` — the union is the whole point
* (objectui#5110), so the error arm is checked FIRST and there is no path from
* a failure to the empty state's "Safe to delete."
*/
export function ReferencesPanel({
state,
locale,
onRetry,
}: {
state: ReferencesState;
locale?: string;
onRetry: () => void;
}) {
if (state.status === 'error') {
return (
<Empty data-testid="refs-error">
<EmptyTitle className="flex items-center justify-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
{t('engine.edit.refsErrorTitle', locale)}
</EmptyTitle>
<EmptyDescription>
{t('engine.edit.refsErrorDesc', locale)}
</EmptyDescription>
{state.message ? (
<p
data-testid="refs-error-cause"
className="mt-2 max-w-full break-words font-mono text-xs text-muted-foreground"
>
{state.message}
</p>
) : null}
<Button
variant="outline"
size="sm"
className="mt-3"
data-testid="refs-retry"
onClick={onRetry}
>
{t('engine.edit.refsRetry', locale)}
</Button>
</Empty>
);
}
if (state.status !== 'loaded') {
return (
<div
data-testid="refs-scanning"
className="text-sm text-muted-foreground flex items-center gap-2"
>
<Loader2 className="h-4 w-4 animate-spin" /> {t('engine.edit.refsScanning', locale)}
</div>
);
}
const refs = state.items;
if (refs.length === 0) {
return (
<Empty data-testid="refs-empty">
<EmptyTitle>{t('engine.edit.refsEmptyTitle', locale)}</EmptyTitle>
<EmptyDescription>
{t('engine.edit.refsEmptyDesc', locale)}
</EmptyDescription>
</Empty>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left">{t('engine.edit.refsColFromType', locale)}</th>
<th className="px-3 py-2 text-left">{t('engine.edit.refsColFromName', locale)}</th>
<th className="px-3 py-2 text-left">{t('engine.edit.refsColPath', locale)}</th>
</tr>
</thead>
<tbody className="divide-y">
{refs.map((r, i) => (
<tr key={i} className="hover:bg-accent/50">
<td className="px-3 py-2">
<Badge variant="outline" className="text-[10px] font-mono">
{r.fromType}
</Badge>
</td>
<td className="px-3 py-2 font-mono text-xs">{r.fromName}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{r.path}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@object-ui/components';
import { t, tFormat, translateConsoleValue, useMetadataLocale } from './i18n.js';
import { PageShell } from './PageShell.js';
import { t, tFormat, translateConsoleValue, useMetadataLocale } from '../metadata-admin/i18n.js';
import { PageShell } from '../metadata-admin/PageShell.js';
import {
useMetadataClient,
useMetadataTypes,
type RichMetadataTypeEntry,
} from './useMetadata.js';
} from '../metadata-admin/useMetadata.js';

export interface MetadataResourceHistoryPageProps {
type?: string;
Expand Down
Loading
Loading