Skip to content

Commit 0ab4a09

Browse files
fix(studio): open system-scope packages read-only, with Customize (#29591)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbSQv4ZnRLpEwaigh35YLReU)_ Fixes #29567 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6586202344). ## 维护者速读 - 改了什么:Studio 打开系统级(scope 为 system/cloud)的包时,按服务端的判定读成只读:顶栏包名旁出现「只读」锁标,条目头部出现「自定义」,修改存进该包配对的 .custom 定制包,和代码启动的包一样。包切换器的下拉列表照旧不列系统包。 - 为什么改:包列表的解析为切换器把系统包过滤掉了,Studio 的写入判定只查切换器的条目,查不到就当成「没有判定」而放开编辑;作者改完保存才被 403 NOT_OVERRIDABLE 拒绝,而服务端其实接受把它定制进配对包(ensure-customization 回 201)。 - 风险与回滚:只改 app-shell 两个文件的判定读取(切换器查不到时改读列表行上服务端的 writable),不加文案、不动服务端、不动下拉列表。回滚即撤销本 PR。 - 先看:`StudioDesignSurface.tsx` 里写入判定的 effect(切换器无条目时读 `listedPackageEntry`);`PackageSwitcher` 的 `current` 回退到新 prop `openEntry`;新测试 `StudioDesignSurface.kernelReadOnly-29567.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.kernelReadOnly-29567.test.tsx`,预期 4 个用例全过(换回 main 的 StudioDesignSurface.tsx 时其中 3 个转红)。 - 需要你做的:无,席位落地。 ## What changes - **`packages-io.ts`: new `listedPackageEntry(manifests, id)`.** A `PkgEntry` for any package the list names, read from the listed row (`parseListedPackageManifests`), the server's `writable` verdict included. Unlike `parsePackages` it answers for a kernel package (manifest scope `system` / `cloud`), which the switcher's list still leaves out. `parsePackages` is unchanged. - **`StudioDesignSurface.tsx`, the surface's write gate.** When the switcher's entries do not carry the open package, the gate now reads `listedPackageEntry` instead of settling on "no verdict". A system package therefore reads `writable: false`: the surface is read-only, the base facts (listed version, listed pair) are read, and the customization prop is built, so Customize runs the existing ensure path. `pkgWritable` stays "still asking" until that answer lands, so the surface never opens editable first. An id the list does not name still ends in "package not found", as before. - **`PackageSwitcher` (same file).** New prop `openEntry`, the surface's entry for the open package. The trigger's `current` falls back to it once the list has answered, so the chip shows the package's name and the read-only lock. The menu is unchanged: kernel packages stay out of it, and "Manage package" still reads only the list's own entry. - No new strings, no server change. ## Tests - **New `StudioDesignSurface.kernelReadOnly-29567.test.tsx`.** The real `StudioDesignSurface` and metadata client over a fetch stub that lists `com.objectstack.plugin-auth` (scope `system`, namespace `sys`, `writable: false`) beside a writable package. Pins: the chip names the package and shows `studio-pkg-readonly`, and the menu still does not offer it; Customize is offered on its dashboard and the first edit POSTs `ensure-customization` (201) and writes the pair's draft declaring the override at the listed version; a system package the door refuses (its manifest states no version, so the pair's `^major` range cannot be declared) shows Customize disabled with the `noVersion` reason and never asks the door; CONTROL, a writable package opens with no chip and no Customize. - **`packages-io.writableVerdict.test.ts`: one case.** `listedPackageEntry` answers a system row with the server's `writable: false` and its namespace while `parsePackages` still leaves it out, and answers `null` for an id the list does not name. - **Red on main:** `StudioDesignSurface.tsx` restored to `208d3e2e` (`git restore --source`): 3 of the 4 new cases red (chip, Customize, door-refused), CONTROL green. Restored with `git checkout HEAD --`, `git diff HEAD` empty. - **Ablation of the chip fallback** (`scripts/ablation-replace.mjs`, `const current = listedCurrent ?? (…openEntry…)` replaced by `listedCurrent`, anchor 1 to 0): the two cases asserting the chip go red, the Customize case stays green (it reads the gate, not the chip). Restored: blob equals HEAD, `git diff HEAD` empty. ## Acceptance notes - **Kernel packages stay out of the switcher's menu** on purpose: the claim fixed the verdict, not which packages are offered. An open one is reached by URL (the card's measurement path) or from a link. - **A system package's pair is listed without its base.** Once a pair exists (`com.objectstack.plugin-auth.custom`), `foldCustomizationPairs` cannot fold it under a base the list leaves out, so the pair shows as its own row; opening it lands on the base through the existing pair URL redirect (`customizationPairBaseOf`; read in code, not measured). Noted, not changed. - **Shared `sys` namespace.** Measured on a fresh `pnpm dev:crm` at this head: 18 of the 25 listed packages are scope `system` with namespace `sys` (plugin-auth, plugin-security, audit, rest.api, service-automation, service.settings and others). `POST /api/v1/packages/com.objectstack.plugin-auth/ensure-customization` answered 201 (`sys_custom`, `^3`); after it, the same door for `com.objectstack.plugin-security` and `com.objectstack.audit` answered `422 NAMESPACE_CONFLICT` ("its namespace 'sys_custom' is already owned by package 'com.objectstack.plugin-auth.custom'"). So only the first `sys` package can get a pair. The console cannot read that answer before asking (it is the registry's namespace owners), so such a package shows Customize and the refusal arrives at the first save, in the door's own sentence. Whether more than one system package should be customizable is outside this card and goes to the seat as a finding; the code is left as it is. - The listed-manifest fallback for a server that predates `writable` (`scope !== 'project'`) is unchanged, and it reads a scope-`system` row as writable; every current server serves the verdict on every row. ## Verification All on head f933fef (base 208d3e2), in worktree `objectstack-issue-29567`. <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2` over the 2 touched test files: 2 files, 11 tests passed. - The same over every app-shell test naming `StudioDesignSurface` or `packages-io` (`git grep -l`), in five batches: 210 files passed, 1409 tests passed. Narrowed: CI's Test UI runs the rest of app-shell. - `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped; 4972 tests passed. - `pnpm --filter @object-ui/app-shell run type-check` (`tsc --noEmit` and `tsc -p tsconfig.test.json`): exit 0. - `pnpm exec eslint` over the four touched ui files: exit 0, 0 errors (46 warnings, all on lines this PR does not touch). </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived families. All 34 ran and exited 0, including `check-sdui-manifest.mjs` and its `--self-test`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-closing-keyword-parity`, `check-empty-changeset` and `check:published-files`. - `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui check:new-line-citations`: 0 new citations. - Dogfood battery: n/a, because there is no `!` in the title and no (narrowing) arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jV3kBe3QqeCq1aYvu8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9d9549 commit 0ab4a09

5 files changed

Lines changed: 364 additions & 20 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
Studio now reads an open system-scope (kernel) package as the server judges it: read-only, with the read-only chip
9+
and Customize into its paired customization package, as for a code-booted package. It used to open such a package
10+
editable, because the switcher's list leaves kernel packages out and the surface took that absence for "no verdict",
11+
so every edit was refused on save. The switcher still does not offer kernel packages.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29567 — a system-scope package opened in Studio reads as the
5+
* server judges it: read-only, with Customize into its paired customization
6+
* package. The switcher's list leaves kernel packages out (which packages it
7+
* offers), and the surface used to take that absence for "no verdict", so the
8+
* package opened editable, with no read-only chip and no Customize, and every
9+
* edit was refused on save.
10+
*
11+
* The REAL `StudioDesignSurface` and the REAL metadata client over a fetch
12+
* stub serving the package list (the system row carries `writable: false`, as
13+
* the server's write predicate answers for scope `system`), the ensure door
14+
* and the dashboard's layered read. Stubbed: the dashboard canvas (its label
15+
* and one edit), `DraftChangesPanel` and the session.
16+
*
17+
* Pinned:
18+
* - the chip names the package and says it is read-only; the menu still
19+
* does not offer it;
20+
* - Customize is offered on its dashboard, and the first edit asks the door
21+
* and writes the pair's draft declaring the override at the listed version;
22+
* - a system package the door refuses (its manifest states no version, so the
23+
* pair's dependency range cannot be declared): Customize disabled, saying
24+
* so, and the door is never asked;
25+
* - CONTROL: an ordinary writable package opens with no chip and no Customize.
26+
*/
27+
28+
import '@testing-library/jest-dom/vitest';
29+
import * as React from 'react';
30+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
31+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
32+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
33+
34+
/** A system-scope package, as the platform ships its own (manifest scope `system`). */
35+
const BASE = 'com.objectstack.plugin-auth';
36+
const PAIR = 'com.objectstack.plugin-auth.custom';
37+
/** An ordinary writable package, listed beside it. */
38+
const OWN = 'com.example.field_ops';
39+
40+
const NAV = [{ id: 'nav_overview', type: 'dashboard', label: 'System overview', dashboardName: 'system_overview' }];
41+
const APP = { name: 'auth_app', label: 'Auth', navigation: NAV };
42+
const OVERVIEW = { name: 'system_overview', label: 'System overview', widgets: [] };
43+
44+
const srv = vi.hoisted(() => ({
45+
baseVersion: '3.2.0' as string | undefined,
46+
ensure: [] as number[],
47+
saves: [] as Array<{ path: string; search: string; body: Record<string, unknown> }>,
48+
}));
49+
50+
const session = vi.hoisted(() => ({
51+
posture: undefined as string | undefined,
52+
user: null as null | { id: string; name: string; email: string; isPlatformAdmin?: boolean },
53+
}));
54+
55+
vi.mock('@object-ui/auth', async (importOriginal) => {
56+
const actual = await importOriginal<typeof import('@object-ui/auth')>();
57+
const getAuthConfig = async () => ({ features: session.posture ? { tenancyPosture: session.posture } : {} });
58+
return {
59+
...actual,
60+
useAuth: () => ({ ...actual.useAuth(), user: session.user, getAuthConfig }),
61+
};
62+
});
63+
64+
vi.mock('@object-ui/react', async (importOriginal) => {
65+
const actual = await importOriginal<Record<string, unknown>>();
66+
return { ...actual, useAdapter: () => ({}) };
67+
});
68+
69+
vi.mock('sonner', () => ({
70+
toast: { warning: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn(), message: vi.fn(), dismiss: vi.fn() },
71+
Toaster: () => null,
72+
}));
73+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
74+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
75+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
76+
vi.mock('../../preview/DraftChangesPanel', () => ({
77+
DraftChangesPanel: ({ packageId, onPublish }: { packageId: string | null; onPublish?: () => void | Promise<void> }) => (
78+
<div data-testid="changes-panel" data-package={packageId ?? ''}>
79+
{onPublish && (
80+
<button type="button" data-testid="publish-all-drafts" onClick={() => void onPublish()}>
81+
publish
82+
</button>
83+
)}
84+
</div>
85+
),
86+
}));
87+
88+
import { StudioDesignSurface } from './StudioDesignSurface';
89+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
90+
import { tFormat } from '../metadata-admin/i18n';
91+
92+
window.matchMedia = ((query: string) => ({
93+
matches: false,
94+
media: query,
95+
onchange: null,
96+
addEventListener: () => {},
97+
removeEventListener: () => {},
98+
addListener: () => {},
99+
removeListener: () => {},
100+
dispatchEvent: () => false,
101+
})) as unknown as typeof window.matchMedia;
102+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
103+
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ??
104+
class {
105+
observe() {}
106+
unobserve() {}
107+
disconnect() {}
108+
};
109+
110+
function BoardCanvas({ draft, onPatch }: { draft: Record<string, unknown>; onPatch?: (patch: Record<string, unknown>) => void }) {
111+
return (
112+
<div>
113+
<p data-testid="board-label">{String(draft?.label ?? '')}</p>
114+
<button type="button" onClick={() => onPatch?.({ label: `${String(draft?.label ?? '')} (ours)` })}>
115+
patch the board
116+
</button>
117+
</div>
118+
);
119+
}
120+
121+
function json(body: unknown, status = 200): Response {
122+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
123+
}
124+
125+
function packageRows() {
126+
return [
127+
{
128+
manifest: {
129+
id: BASE,
130+
name: 'Authentication',
131+
namespace: 'sys',
132+
scope: 'system',
133+
...(srv.baseVersion ? { version: srv.baseVersion } : {}),
134+
},
135+
writable: false,
136+
},
137+
{ manifest: { id: OWN, name: 'Field Ops', namespace: 'field_ops', version: '0.1.0' }, writable: true },
138+
];
139+
}
140+
141+
beforeAll(() => {
142+
registerMetadataPreview('dashboard', BoardCanvas as never);
143+
});
144+
145+
beforeEach(() => {
146+
srv.baseVersion = '3.2.0';
147+
srv.ensure.length = 0;
148+
srv.saves.length = 0;
149+
session.posture = undefined;
150+
session.user = null;
151+
vi.stubGlobal(
152+
'fetch',
153+
vi.fn(async (input: unknown, init?: RequestInit) => {
154+
const url = new URL(String(input), 'http://localhost');
155+
const path = url.pathname;
156+
const method = init?.method ?? 'GET';
157+
if (path === '/api/v1/packages') return json({ success: true, data: packageRows() });
158+
if (path === `/api/v1/packages/${BASE}/ensure-customization` && method === 'POST') {
159+
const created = srv.ensure.length === 0;
160+
srv.ensure.push(created ? 201 : 200);
161+
return json(
162+
{
163+
success: true,
164+
data: { basePackageId: BASE, packageId: PAIR, namespace: 'sys_custom', dependencyRange: '^3', created, writable: true },
165+
},
166+
created ? 201 : 200,
167+
);
168+
}
169+
if (path === '/api/v1/meta/_drafts') return json({ drafts: [] });
170+
if (path === '/api/v1/meta/app') return json([{ name: 'auth_app', label: 'Auth' }]);
171+
if (path === '/api/v1/meta/app/auth_app/layers') return json({ code: null, overlay: null, overlayScope: null, effective: APP });
172+
if (path === '/api/v1/meta/dashboard/system_overview/layers') {
173+
return json({ code: OVERVIEW, overlay: null, overlayScope: null, effective: OVERVIEW });
174+
}
175+
if (path === '/api/v1/meta/dashboard/system_overview' && method === 'PUT') {
176+
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
177+
srv.saves.push({ path, search: url.search, body });
178+
if (url.searchParams.get('package') !== PAIR) {
179+
return json({ success: false, error: { code: 'NOT_OVERRIDABLE', message: 'read-only' } }, 403);
180+
}
181+
return json({ success: true, version: `v${srv.saves.length}`, state: 'draft' });
182+
}
183+
if (path.endsWith('/layers')) return json({ code: null, overlay: null, overlayScope: null, effective: null });
184+
if (/^\/api\/v1\/meta\/[^/]+\/[^/]+$/.test(path)) return json({}, 404);
185+
return json([]);
186+
}) as unknown as typeof fetch,
187+
);
188+
});
189+
190+
afterEach(() => {
191+
cleanup();
192+
vi.unstubAllGlobals();
193+
});
194+
195+
function renderAt(packageId: string) {
196+
return render(
197+
<MemoryRouter initialEntries={[`/studio/${packageId}/interfaces`]}>
198+
<Routes>
199+
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
200+
</Routes>
201+
</MemoryRouter>,
202+
);
203+
}
204+
205+
const chip = () => within(screen.getByTestId('studio-shell-header')).getByTestId('studio-pkg-chip');
206+
const header = () => screen.getByTestId('if-item-header');
207+
208+
async function openOverview(): Promise<void> {
209+
await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent(/^System overview/), { timeout: 8000 });
210+
}
211+
212+
describe('a system-scope package opens read-only, as the server judges it (objectstack#29567)', () => {
213+
it('the chip names the package and says it is read-only; the menu still does not offer it', async () => {
214+
renderAt(BASE);
215+
await waitFor(() => expect(within(chip()).getByTestId('studio-pkg-readonly')).toBeInTheDocument(), { timeout: 8000 });
216+
expect(within(chip()).getByText('Authentication')).toBeInTheDocument();
217+
218+
fireEvent.click(chip());
219+
const menu = await screen.findByRole('dialog');
220+
expect(within(menu).getByText('Field Ops')).toBeInTheDocument();
221+
expect(within(menu).queryByText('Authentication')).toBeNull();
222+
});
223+
224+
it('offers Customize on its dashboard, and the first edit asks the door and writes the pair', async () => {
225+
renderAt(BASE);
226+
await openOverview();
227+
fireEvent.click(await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 }));
228+
229+
fireEvent.click(screen.getByRole('button', { name: 'patch the board' }));
230+
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 });
231+
expect(srv.ensure).toEqual([201]);
232+
const [saved] = srv.saves;
233+
expect(new URLSearchParams(saved!.search).get('package')).toBe(PAIR);
234+
expect(saved!.body).toStrictEqual({
235+
...OVERVIEW,
236+
label: 'System overview (ours)',
237+
overrides: { package: BASE, basedOn: '3.2.0' },
238+
});
239+
});
240+
241+
it('a system package the door refuses (no manifest version): Customize disabled, saying so, door never asked', async () => {
242+
srv.baseVersion = undefined;
243+
renderAt(BASE);
244+
await openOverview();
245+
const blocked = await within(header()).findByTestId('studio-customize-blocked', undefined, { timeout: 8000 });
246+
expect(blocked).toHaveAttribute('data-block', 'noVersion');
247+
expect(blocked).toHaveAttribute('title', tFormat('engine.studio.customize.noVersion', 'en', { package: 'Authentication' }));
248+
expect(within(blocked).getByTestId('studio-customize')).toBeDisabled();
249+
expect(within(chip()).getByTestId('studio-pkg-readonly')).toBeInTheDocument();
250+
expect(srv.ensure).toEqual([]);
251+
});
252+
253+
it('CONTROL: an ordinary writable package opens with no read-only chip and no Customize', async () => {
254+
renderAt(OWN);
255+
await waitFor(() => expect(chip()).toHaveAttribute('data-pkg-list-state', 'loaded'), { timeout: 8000 });
256+
await waitFor(() => expect(within(chip()).getByText('Field Ops')).toBeInTheDocument());
257+
expect(within(chip()).queryByTestId('studio-pkg-readonly')).toBeNull();
258+
expect(screen.queryByTestId('studio-customize')).toBeNull();
259+
expect(screen.queryByTestId('studio-customize-blocked')).toBeNull();
260+
});
261+
});

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

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ import {
391391
fetchListedPackageManifests,
392392
fetchPackages,
393393
listedCustomizationPairOf,
394+
listedPackageEntry,
394395
listedPackageVersion,
395396
prefixObjectName,
396397
type EnsuredCustomizationPackage,
@@ -907,9 +908,17 @@ function PackageSwitcher({
907908
tab,
908909
beforeNavigate,
909910
app = null,
911+
openEntry = null,
910912
}: {
911913
/** The package's app, drawn as the chip's icon tile (objectui#12459); `null` draws the neutral tile. */
912914
app?: AppMarkApp | null;
915+
/**
916+
* objectstack#29567 — the surface's own entry for the open package, which
917+
* the trigger reads when this list does not carry it: a kernel package is
918+
* left out of the list (it is not offered), yet when it is open the chip
919+
* still names it and says it is read-only. The menu never reads it.
920+
*/
921+
openEntry?: PkgEntry | null;
913922
/** The open package, or `null` in the package-less scope (objectui#11553). */
914923
packageId: string | null;
915924
tab: string;
@@ -985,7 +994,9 @@ function PackageSwitcher({
985994
};
986995
}, [packageId]);
987996

988-
const current = pkgs?.find((p) => p.id === packageId) ?? null;
997+
const listedCurrent = pkgs?.find((p) => p.id === packageId) ?? null;
998+
// Only once the list has answered, so the three trigger states stay its own.
999+
const current = listedCurrent ?? (pkgs !== null && openEntry?.id === packageId ? openEntry : null);
9891000
// objectstack#28572 (ADR-0146 D3) — a base's paired `<base>.custom` has no
9901001
// row of its own: it is folded under its base, which names it.
9911002
const folded = React.useMemo(() => foldCustomizationPairs(pkgs ?? []), [pkgs]);
@@ -1493,10 +1504,10 @@ function PackageSwitcher({
14931504
</div>
14941505

14951506
<div className="mt-1 space-y-0.5 border-t pt-1.5">
1496-
{current && (
1507+
{listedCurrent && (
14971508
<button
14981509
type="button"
1499-
onClick={() => void openManage(current.id)}
1510+
onClick={() => void openManage(listedCurrent.id)}
15001511
disabled={manageBusy}
15011512
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
15021513
>
@@ -1632,26 +1643,43 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re
16321643
listedIds.catch(() => {});
16331644
const listedManifests = fetchListedPackageManifests();
16341645
listedManifests.catch(() => {});
1646+
const settle = (entry: PkgEntry | null) => {
1647+
setPkgWritable(entry?.writable ?? null);
1648+
setPkgEntry(entry);
1649+
if (!entry || entry.writable) return;
1650+
listedManifests.then(
1651+
(manifests) => {
1652+
if (cancelled) return;
1653+
setBaseFacts({
1654+
for: packageId,
1655+
basedOn: listedPackageVersion(manifests, packageId),
1656+
listedPair: listedCustomizationPairOf(manifests, packageId),
1657+
});
1658+
},
1659+
() => {},
1660+
);
1661+
};
16351662
fetchPackages()
16361663
.then((list) => {
16371664
if (cancelled) return;
16381665
const entry = list.find((p) => p.id === packageId) ?? null;
1639-
setPkgWritable(entry?.writable ?? null);
1640-
setPkgEntry(entry);
1641-
if (entry && !entry.writable) {
1642-
listedManifests.then(
1643-
(manifests) => {
1644-
if (cancelled) return;
1645-
setBaseFacts({
1646-
for: packageId,
1647-
basedOn: listedPackageVersion(manifests, packageId),
1648-
listedPair: listedCustomizationPairOf(manifests, packageId),
1649-
});
1650-
},
1651-
() => {},
1652-
);
1666+
if (entry) {
1667+
settle(entry);
1668+
return;
16531669
}
1654-
if (entry) return;
1670+
// objectstack#29567 — the switcher's entries leave the kernel packages
1671+
// out, but an open one is still judged: the listed row carries the
1672+
// server's verdict (read-only by scope), so the surface reads it
1673+
// read-only and offers Customize, as for a code-booted base.
1674+
// `pkgWritable` stays "still asking" until that answer lands.
1675+
listedManifests.then(
1676+
(manifests) => {
1677+
if (!cancelled) settle(listedPackageEntry(manifests, packageId));
1678+
},
1679+
() => {
1680+
if (!cancelled) settle(null);
1681+
},
1682+
);
16551683
listedIds.then(
16561684
(ids) => {
16571685
if (!cancelled) setPkgMissing(!ids.has(packageId));
@@ -2484,7 +2512,13 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re
24842512
<HomeIcon className="h-4 w-4" />
24852513
</button>
24862514
<div className="flex min-w-0">
2487-
<PackageSwitcher packageId={packageId} tab={tab} beforeNavigate={confirmLeavePillar} app={packageApp} />
2515+
<PackageSwitcher
2516+
packageId={packageId}
2517+
tab={tab}
2518+
beforeNavigate={confirmLeavePillar}
2519+
app={packageApp}
2520+
openEntry={pkgEntry?.id === packageId ? pkgEntry : null}
2521+
/>
24882522
</div>
24892523
</div>
24902524
<nav className={STUDIO_HEADER_PILLAR_NAV} data-testid="studio-pillar-nav">

0 commit comments

Comments
 (0)