From 4d148d3617ed6d60df0b6137294d64342babbad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:51:18 +0000 Subject: [PATCH 1/2] fix(app-shell): Studio Interfaces names a package with no app by its display name (objectui#12718) The pillar seeded its app label with the package id and the app resolve returned on the no-app branch before setting one, so the rail heading and the pillar title read the id beside a top bar reading the package name. The surface now hands the pillar the name from the package list it already reads for the writability gate, and the pillar holds no app label until an app is found. A draft-only app is named by its `_drafts` row's label. Claude-Session: https://claude.ai/code/session_017wgGVVegwUuFc4uhBx7Lot Co-authored-by: Claude --- ...gnSurface.noAppPackageLabel-12718.test.tsx | 181 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 27 ++- 2 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx new file mode 100644 index 0000000000..8017235848 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12718 — the Interfaces pillar of a package that has no app yet + * names the package by its display name, the one the Studio top bar prints, + * and never by its id. + * + * `InterfacesPillar` seeded its app label with `packageId`, and the app + * resolve returned on the no-app branch before it ever set one, so the rail + * heading read `com.qa.recheck · Navigation` and the pillar title + * `com.qa.recheck` beside a top bar reading `QA Recheck`. The surface now + * hands the pillar the name from the package list it already reads for the + * writability gate (no new request), and the pillar holds no app label until + * an app is found. + * + * A draft-only app is named by the label its `_drafts` row serves + * (objectstack-ai/objectstack#22200), not by its machine name. The controls: + * a published app keeps its own label, and a package that declares no name + * still reads as its id, the only name it has. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PACKAGE_ID = 'com.qa.recheck'; + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +/** What the mocked server answers for this package's app. */ +const state: { + published: Array>; + drafts: Array>; +} = { published: [], drafts: [] }; + +const mockClient = { + list: vi.fn(async (type: string) => (type === 'app' ? state.published : [])), + listDrafts: vi.fn(async (opts?: { type?: string }) => (opts?.type === 'app' ? state.drafts : [])), + layered: vi.fn(async (_t: string, name: string) => ({ effective: { name } })), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +/** The package list the top bar and the writability gate read. */ +function servePackage(manifest: Record) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => + String(input) === '/api/v1/packages' + ? answer({ success: true, data: { packages: [{ manifest, writable: true }] } }) + : answer([]), + ), + ); +} + +function renderInterfaces() { + render( + + + } /> + + , + ); +} + +/** The pillar's own text: the rail heading and the item header title, not the top bar. */ +function pillarText(): string { + const header = screen.getByTestId('studio-shell-header'); + let text = ''; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!header.contains(node)) text += `${node.textContent ?? ''}\n`; + } + return text; +} + +/** Waits for the top bar to have the package list in hand. */ +async function packageListLoaded() { + const header = screen.getByTestId('studio-shell-header'); + await waitFor(() => + expect(within(header).getByTestId('studio-pkg-chip')).toHaveAttribute('data-pkg-list-state', 'loaded'), + ); +} + +beforeEach(() => { + state.published = []; + state.drafts = []; + for (const fn of Object.values(mockClient)) fn.mockClear(); + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Studio Interfaces names a package with no app by its display name (objectui#12718)', () => { + it('no app: the rail heading and the pillar title read the package name, never its id', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + renderInterfaces(); + + expect(await screen.findByText('QA Recheck · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(mockClient.list).toHaveBeenCalledWith('app', { packageId: PACKAGE_ID }); + expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PACKAGE_ID, type: 'app' }); + const text = pillarText(); + // The title is the package name on a line of its own; the heading is the other line naming it. + expect(text.split('\n').filter((line) => line.trim() === 'QA Recheck')).not.toHaveLength(0); + expect(text).not.toContain(PACKAGE_ID); + // The package list is the one the surface already reads: no request is added for the name. + const reads = (fetch as unknown as ReturnType).mock.calls.filter( + ([input]) => String(input) === '/api/v1/packages', + ); + expect(reads.length).toBeLessThanOrEqual(1); + }); + + it('a draft-only app: its label from the `_drafts` row, not its machine name', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + state.drafts = [{ type: 'app', name: 'recheck_app', label: 'Recheck Desk', packageId: PACKAGE_ID }]; + renderInterfaces(); + + expect(await screen.findByText('Recheck Desk · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(pillarText()).not.toContain('recheck_app ·'); + }); + + it('CONTROL: a published app keeps its own label over the package name', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + state.published = [{ name: 'recheck_app', label: 'Recheck Desk' }]; + renderInterfaces(); + + expect(await screen.findByText('Recheck Desk · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(screen.queryByText('QA Recheck · Navigation')).toBeNull(); + }); + + it('CONTROL: a package that declares no name reads as its id, the only name it has', async () => { + servePackage({ id: PACKAGE_ID }); + renderInterfaces(); + + await packageListLoaded(); + expect(await screen.findByText(`${PACKAGE_ID} · Navigation`, undefined, { timeout: 8000 })).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index a3db049181..9a25941fe3 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1217,6 +1217,11 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // pillar's `?sel=nav:` deep link tells them apart, so that it does not act // on a write state nobody has answered yet (objectui#11153). const [pkgWritable, setPkgWritable] = React.useState(undefined); + // objectui#12718 — the package's display name, from the same answer: the + // Interfaces pillar names a package that has no app yet by it. Held with + // the id it answered for, so a switch never hands the next package the + // previous one's name while its own read is in flight. + const [pkgEntry, setPkgEntry] = React.useState(null); React.useEffect(() => { // objectui#11553 — the package-less scope has no package to look up; its // writability is decided below, not fetched. @@ -1225,7 +1230,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React setPkgWritable(undefined); fetchPackages() .then((list) => { - if (!cancelled) setPkgWritable(list.find((p) => p.id === packageId)?.writable ?? null); + if (cancelled) return; + const entry = list.find((p) => p.id === packageId) ?? null; + setPkgWritable(entry?.writable ?? null); + setPkgEntry(entry); }) .catch((e: unknown) => { // Staying ungated is still right — the server gate is the authority @@ -1954,6 +1962,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React `${s?.type ?? ''}:${s?.name ?? export function InterfacesPillar({ packageId, + packageLabel, publishNonce = 0, draftNonce = 0, onDraftSaved, @@ -2946,6 +2956,10 @@ export function InterfacesPillar({ onHeldEditChange, }: { packageId: string; + /** objectui#12718 — the package's display name, as the Studio top bar + * reads it; `undefined` until the package list answers. The pillar names a + * package with no app by it, so the id shows only when neither exists. */ + packageLabel?: string; publishNonce?: number; /** Bumped when a draft is saved elsewhere (e.g. the header's create-app flow) — a * re-resolve signal so a just-created app appears without a reload. */ @@ -3000,7 +3014,10 @@ export function InterfacesPillar({ // in the designer locale, so the rail heading follows a designer-language // switch as the nav rows do. ⛔ Never `String()` it: a map reads // `[object Object]`. - const [appLabel, setAppLabel] = React.useState(packageId); + // objectui#12718 — `null` until an app is found. A package with no app yet + // is named by its display name, and by its id only when it declares none. + const [loadedAppLabel, setAppLabel] = React.useState(null); + const appLabel: I18nLabel = loadedAppLabel ?? packageLabel ?? packageId; const [appName, setAppName] = React.useState(null); const [appDraft, setAppDraft] = React.useState>({}); // objectui#11272 — the app `appDraft` was loaded for, `app:NAME`: written @@ -3369,7 +3386,11 @@ export function InterfacesPillar({ const d = drafts?.[0]; if (d?.name) { name = String(d.name); - label = String(d.name); + // objectui#12718 — the `_drafts` row serves the app's label + // (objectstack-ai/objectstack#22200), as the shell header's + // draft-only read already takes it. + const rowLabel = (d as { label?: I18nLabel | null }).label; + label = rowLabel ?? String(d.name); } } if (!name) { From a0dcbe37c9794e58ae3d2c12659c82d9bb9f1c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:52:34 +0000 Subject: [PATCH 2/2] chore(changeset): declare the objectui#12718 app-shell patch Claude-Session: https://claude.ai/code/session_017wgGVVegwUuFc4uhBx7Lot Co-authored-by: Claude --- .changeset/12718-studio-no-app-package-label.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/12718-studio-no-app-package-label.md diff --git a/.changeset/12718-studio-no-app-package-label.md b/.changeset/12718-studio-no-app-package-label.md new file mode 100644 index 0000000000..91ecc73f96 --- /dev/null +++ b/.changeset/12718-studio-no-app-package-label.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio Interfaces names a package that has no app yet by its display name, the one the top bar prints, instead of its id (objectui#12718). The rail heading read `com.qa.recheck · Navigation` and the pillar title `com.qa.recheck` beside a top bar reading `QA Recheck`; both now read `QA Recheck`. The name comes from the package list the Studio surface already reads for its read-only gate, so no request is added. A package whose manifest declares no name still reads as its id, the only name it has. + +A draft-only app (created, never published) is now named by the label its pending-draft row serves, as the shell header already names it, instead of its machine name. A published app keeps its own label. + +Clause-②: no +No export, type member, spec key or language-pack key is added or removed. `InterfacesPillar` gains an internal `packageLabel` prop; it is not exported from the package entry.