Skip to content

Commit b8f596c

Browse files
fix(app-shell): Studio Interfaces item header names a label-less object entry by its label, as the rail does (#26974)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8LSNZTBKB5UepDXQ6qob588)_ Fixes #26746 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio「界面」条目标题(标题、读屏说明、定义抽屉标题)改为与左侧导航行同一套标签解析,每次渲染现解析。 - 为什么改:无标签的对象导航项(脚手架生成的就是这种)标题显示机器名 `repairs_ticket`,而侧栏已显示「Repair Ticket」。 - 风险与回滚:仅 app-shell 一个私有包、一个文件的显示文本;回滚即 revert 本 PR,无数据或契约变化。 - 先看:`StudioDesignSurface.tsx` 中新增的 `ifTitleText` 一段;其次新测试文件 `StudioDesignSurface.itemHeaderLabel-26746.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.itemHeaderLabel-26746.test.tsx`,预期 3 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes The Interfaces item header read `current.label || current.name` in three places: the header title, its screen-reader caption beside the kind chip, and the definition sheet's title (`engine.studio.if.definitionTitle`). `current.label` is the open leaf's display text frozen when its Surface was made. On the first load that happens before the resolver knows every label. For example, a draft-only object's `_drafts` row lands after the app load picks its first leaf. The rail asks the resolver again on every render, so it read "Repair Ticket" while the header kept "repairs_ticket · Object". The three sites now read one value, `ifTitleText`. It finds the open leaf's entry in `railTree` (the tree the rail draws, served text included) by `navId` and names it through the rail's own `navEntryLabelText` with the same `useNavTargetLabel` resolver. No second resolver is added. A Surface without a `navId` keeps `current.label`, and an empty text keeps the existing `current.name` fallback. ## Why The rail and the header name the same leaf, and the label wins over the machine name everywhere (ruling B of objectui#11201, the `navEntryLabelText` contract). Triage 6448317687: "Resolve the object label the way the rail does." ## Tests New `StudioDesignSurface.itemHeaderLabel-26746.test.tsx` mounts the whole surface and holds the `_drafts` answer until the header has opened on its first leaf: - a label-less object entry: the header, its caption and its tooltip read "Repair Ticket" once the label arrives, with the machine name only on the tooltip; - a labelled entry: the header reads its authored label, unchanged; - a label-less group: the rail's group header reads what the runtime sidebar's `resolveNavItemLabel` gives it, and a leaf inside it is named alike in the rail and the header. Ablation, after the fix was committed: `scripts/ablation-replace.mjs` replaced the resolver line with `const text = current.label;` (the old `current.label || current.name`). Tests 1 and 3 went red with `Received: repairs_ticket · Object`, the QA symptom; test 2 stayed green. The restore leg was verified by blob: HEAD and disk both `d05fa2404f2a`, `git diff HEAD` empty. The test imports the surface by relative path, so there is no dist leg. ## Acceptance notes - Label-less groups: the rail shows a label-less group's `id` (for example `nav_item_2`, drawn uppercase by the rail group style). This is the runtime's own rule: `resolveNavItemLabel` in `@object-ui/layout` falls to the entry `id` for a group, and the running app's sidebar shows the same text. The header never names a group, because a group is not a Surface. So the rail and the header already agree, and `NavTreeGroup` is unchanged. Test 3 pins that the rail matches the runtime. Giving label-less groups a different text would be a change to the runtime rule, not to this header. - Outside this card's surface, the same frozen-label read remains in two places, reported only: the Automation pillar header's `current.label || current.name`, and the `onSurfaceLabelChange` report to the dock, which reads `current.label`. ## Verification All readings are on head `052aae45`, the last commit before this PR. <details><summary>Derived gates: 24 of 24 exit 0, `--ran` reconciled</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 24 families. Each one ran, its exit code was captured before any pipe, and `--ran` printed "24 derived famil(ies) accounted for — 24 run, 0 NOT-MEASURED". The set includes `node scripts/check-sdui-manifest.mjs` (and `--self-test`), `pnpm --dir ui exec vitest run scripts/` under the lock, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:nul-bytes`, `check:type-check-coverage` and `check:type-check-debt`. All exit 0. </details> <details><summary>Package checks</summary> - `pnpm --filter @object-ui/app-shell type-check`: exit 0 (lock VERDICT command-exit 0). - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/`: Test Files 234 passed (234), Tests 1693 passed (1693) (lock VERDICT command-exit 0). - eslint on the two changed files: 0 errors, 14 warnings, all on pre-existing lines outside this hunk. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. </details> No changeset: `@object-ui/app-shell` is `private: true`, and the diff touches only it, so the PR carries `skip-changeset`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the changed files: 0 errors) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. (nothing publishes; both declaration lines filled in) - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbY5nVXXfxvWqYPEQAe --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbY5nVXXfxvWqYPEQAe)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1986d25 commit b8f596c

2 files changed

Lines changed: 232 additions & 3 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26746 — the Interfaces item header names the open leaf as its
5+
* rail row names it.
6+
*
7+
* A label-less `{ type: 'object', objectName }` entry, as the app scaffold
8+
* writes it, inherits its object's label at render time (objectui#11196). The
9+
* rail asks the console's resolver (`useNavTargetLabel`) on every render; the
10+
* header read `current.label || current.name`, the text frozen when the open
11+
* leaf's Surface was made. When the resolver learns the label after the leaf
12+
* opened — a draft-only object's `_drafts` row (objectui#11862) arrives after
13+
* the app load picks its first leaf — the rail read "Repair Ticket" while the
14+
* header, its screen-reader caption and the definition sheet's title kept
15+
* "repairs_ticket".
16+
*
17+
* - a label-less object entry's header reads the object label once the
18+
* resolver has it, however late it arrives;
19+
* - a labelled entry's header reads its authored label, unchanged;
20+
* - a label-less group reads, in the rail, the text the running app's
21+
* sidebar gives it (`resolveNavItemLabel`), and the header of a leaf
22+
* inside it reads that leaf's label, as its rail row does.
23+
*/
24+
25+
import '@testing-library/jest-dom/vitest';
26+
import * as React from 'react';
27+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
28+
import { act, cleanup, render, screen } from '@testing-library/react';
29+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
30+
import { MetadataCtx } from '@object-ui/react';
31+
import { resolveNavItemLabel } from '@object-ui/layout';
32+
33+
const PKG = 'com.acme.app';
34+
35+
let nav: unknown[] = [];
36+
37+
/** The package's `_drafts` rows, as objectstack serves them since #22200. */
38+
const DRAFT_ROWS = [
39+
{ type: 'object', name: 'repairs_ticket', packageId: PKG, label: 'Repair Ticket', updatedAt: null, updatedBy: null },
40+
];
41+
42+
const mockClient = vi.hoisted(() => ({
43+
list: vi.fn(async (type: string) => (type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [])),
44+
listDrafts: vi.fn(async (): Promise<unknown[]> => []),
45+
listTypes: vi.fn(async () => ({ entries: [] })),
46+
layered: vi.fn(async (_type: string, name: string): Promise<unknown> => ({ effective: { name } })),
47+
getDraft: vi.fn(async () => null),
48+
get: vi.fn(async () => undefined),
49+
references: vi.fn(async () => []),
50+
save: vi.fn(async () => ({})),
51+
publish: vi.fn(async () => ({ success: true })),
52+
reset: vi.fn(async () => ({})),
53+
withPreviewDrafts: vi.fn((): unknown => mockClient),
54+
}));
55+
56+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
57+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
58+
return {
59+
...mod,
60+
useMetadataClient: () => mockClient,
61+
useMetadataTypes: () => ({ loading: false, error: null, entries: [] }),
62+
};
63+
});
64+
65+
const fetchPackagesMock = vi.hoisted(() => vi.fn());
66+
vi.mock('./packages-io', async (importOriginal) => {
67+
const mod = await importOriginal<typeof import('./packages-io')>();
68+
return { ...mod, fetchPackages: (...args: unknown[]) => fetchPackagesMock(...args) };
69+
});
70+
71+
vi.mock('@object-ui/react', async (importOriginal) => {
72+
const mod = await importOriginal<typeof import('@object-ui/react')>();
73+
return { ...mod, useAdapter: () => ({}) };
74+
});
75+
76+
vi.mock('sonner', () => ({
77+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() },
78+
Toaster: () => null,
79+
}));
80+
81+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
82+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
83+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
84+
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
85+
86+
import { StudioDesignSurface } from './StudioDesignSurface';
87+
88+
window.matchMedia = ((query: string) => ({
89+
matches: false,
90+
media: query,
91+
onchange: null,
92+
addEventListener: () => {},
93+
removeEventListener: () => {},
94+
addListener: () => {},
95+
removeListener: () => {},
96+
dispatchEvent: () => false,
97+
})) as unknown as typeof window.matchMedia;
98+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
99+
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ??
100+
class {
101+
observe() {}
102+
unobserve() {}
103+
disconnect() {}
104+
};
105+
106+
/** The host's metadata cache: published only, so the draft-only object is not in it. */
107+
const METADATA = {
108+
apps: [],
109+
objects: [],
110+
dashboards: [],
111+
reports: [],
112+
pages: [],
113+
loading: false,
114+
error: null,
115+
refresh: async () => {},
116+
invalidate: () => {},
117+
ensureType: async () => [],
118+
getItem: async () => null,
119+
getItemsByType: () => [],
120+
getTypeStatus: () => 'ready' as const,
121+
};
122+
123+
/** Releases the held `_drafts` answer: it lands only after the app load opened its first leaf. */
124+
let releaseDrafts: () => void = () => {};
125+
126+
beforeEach(() => {
127+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
128+
mockClient.layered.mockImplementation(async (type: string, name: string) => {
129+
if (type === 'app') return { effective: JSON.parse(JSON.stringify({ name: 'acme_app', label: 'Acme', navigation: nav })) };
130+
return { effective: { name } };
131+
});
132+
fetchPackagesMock.mockReset();
133+
fetchPackagesMock.mockResolvedValue([{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]);
134+
const held = new Promise<void>((resolve) => {
135+
releaseDrafts = resolve;
136+
});
137+
vi.stubGlobal(
138+
'fetch',
139+
vi.fn(async (input: RequestInfo | URL) => {
140+
const url = String(input);
141+
if (url.includes('/_drafts')) await held;
142+
const body = url.includes('/_drafts') ? { drafts: DRAFT_ROWS } : [];
143+
return { ok: true, status: 200, json: async () => body };
144+
}) as unknown as typeof fetch,
145+
);
146+
});
147+
148+
afterEach(() => {
149+
releaseDrafts();
150+
cleanup();
151+
vi.unstubAllGlobals();
152+
});
153+
154+
function renderSurface() {
155+
return render(
156+
<MetadataCtx.Provider value={METADATA as never}>
157+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
158+
<Routes>
159+
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
160+
</Routes>
161+
</MemoryRouter>
162+
</MetadataCtx.Provider>,
163+
);
164+
}
165+
166+
/** The header opens on the first leaf before the `_drafts` rows land; then they land. */
167+
async function openThenLearnLabels(): Promise<HTMLElement> {
168+
const crumb = await screen.findByTestId('if-breadcrumb', undefined, { timeout: 8000 });
169+
await act(async () => {
170+
releaseDrafts();
171+
});
172+
return crumb;
173+
}
174+
175+
describe('objectstack#26746 — the Interfaces item header names the open leaf as its rail row does', () => {
176+
it('a label-less object entry: the header reads the object label the rail reads, learned after the leaf opened', async () => {
177+
nav = [{ id: 'nav_ticket', type: 'object', objectName: 'repairs_ticket' }];
178+
renderSurface();
179+
await openThenLearnLabels();
180+
await screen.findByRole('button', { name: 'Repair Ticket Object' }, { timeout: 8000 });
181+
const crumb = screen.getByTestId('if-breadcrumb');
182+
expect(crumb).toHaveTextContent(/^Repair Ticket/);
183+
expect(crumb).not.toHaveTextContent('repairs_ticket');
184+
expect(screen.getByTestId('if-canvas-caption')).toHaveTextContent(/^Repair Ticket ·/);
185+
// The machine name stays the secondary information, on the tooltip.
186+
expect(crumb).toHaveAttribute('title', expect.stringContaining('object · repairs_ticket'));
187+
});
188+
189+
it('a labelled entry: the header reads its authored label, unchanged', async () => {
190+
nav = [{ id: 'nav_ticket', type: 'object', objectName: 'repairs_ticket', label: 'Open tickets' }];
191+
renderSurface();
192+
await openThenLearnLabels();
193+
await screen.findByRole('button', { name: 'Open tickets Object' }, { timeout: 8000 });
194+
const crumb = screen.getByTestId('if-breadcrumb');
195+
expect(crumb).toHaveTextContent(/^Open tickets/);
196+
expect(crumb).not.toHaveTextContent('Repair Ticket');
197+
});
198+
199+
it('a label-less group: the rail reads what the running sidebar reads, and a leaf inside it is named alike in rail and header', async () => {
200+
const group = { id: 'nav_item_2', type: 'group', children: [{ id: 'nav_ticket', type: 'object', objectName: 'repairs_ticket' }] };
201+
nav = [group];
202+
renderSurface();
203+
await openThenLearnLabels();
204+
const sidebarText = resolveNavItemLabel(group as never);
205+
expect(await screen.findByRole('button', { name: sidebarText }, { timeout: 8000 })).toBeInTheDocument();
206+
await screen.findByRole('button', { name: 'Repair Ticket Object' }, { timeout: 8000 });
207+
expect(screen.getByTestId('if-breadcrumb')).toHaveTextContent(/^Repair Ticket/);
208+
});
209+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4506,6 +4506,26 @@ export function InterfacesPillar({
45064506
: navIcon(currentNavEntry?.type ?? current.type);
45074507
const ifKindName = current ? navKindName(currentNavEntry ?? { type: current.type }, locale) || translateMetadataType(current.type, locale) : '';
45084508
const ifInternalId = current ? `${t('engine.studio.if.internalId', locale)}: ${current.type} · ${current.name}` : undefined;
4509+
// objectstack#26746 — the header names the open leaf as its rail row names
4510+
// it: the row's entry in `railTree`, through the same `navEntryLabelText`
4511+
// and resolver, asked on every render. `current.label` is that text frozen
4512+
// when the Surface was made, so a label the resolver learns later (a
4513+
// draft-only object's `_drafts` label, the console's object metadata) left
4514+
// the header on the machine name while the rail already read the label.
4515+
const ifTitleText = (() => {
4516+
if (!current) return '';
4517+
const find = (nodes: NavNode[]): NavNode | null => {
4518+
for (const node of nodes) {
4519+
if (node.id === current.navId) return node;
4520+
const hit = Array.isArray(node.children) ? find(node.children) : null;
4521+
if (hit) return hit;
4522+
}
4523+
return null;
4524+
};
4525+
const railEntry = current.navId ? find(railTree) : null;
4526+
const text = railEntry ? navEntryLabelText(railEntry as NavEntryLike, locale, targetLabel) : current.label;
4527+
return text || current.name;
4528+
})();
45094529
// objectui#12455 — an object entry's structure is the Data pillar's: the
45104530
// header offers to open it there, through the host's surface channel (no
45114531
// channel, no chip: the hint under the tab row still names the pillar).
@@ -4629,7 +4649,7 @@ export function InterfacesPillar({
46294649
title={
46304650
current ? (
46314651
<>
4632-
{current.label || current.name}
4652+
{ifTitleText}
46334653
<span className="sr-only"> · {ifKindName}</span>
46344654
</>
46354655
) : (
@@ -4644,7 +4664,7 @@ export function InterfacesPillar({
46444664
chips={
46454665
current ? (
46464666
<span className={STUDIO_KIND_CHIP} data-testid="if-canvas-caption" title={ifInternalId}>
4647-
<span className="sr-only">{current.label || current.name} · </span>
4667+
<span className="sr-only">{ifTitleText} · </span>
46484668
{ifKindName}
46494669
</span>
46504670
) : undefined
@@ -5035,7 +5055,7 @@ export function InterfacesPillar({
50355055
>
50365056
<SheetHeader className="shrink-0 border-b px-4 py-3 pr-10">
50375057
<SheetTitle className="text-[13px] font-medium">
5038-
{tFormat('engine.studio.if.definitionTitle', locale, { label: current.label || current.name })}
5058+
{tFormat('engine.studio.if.definitionTitle', locale, { label: ifTitleText })}
50395059
</SheetTitle>
50405060
</SheetHeader>
50415061
{isSourcePage ? (

0 commit comments

Comments
 (0)