Skip to content

Commit f624f27

Browse files
feat(app-shell,console): Studio reaches package-less flows at /studio/~org/automations (objectui#11553) (#11565)
Fixes #11553 Clause-②: yes Implements the ruling triage recorded on objectstack-ai/objectstack#21332 (comment `5946433961`): **the Studio arm, not the package arm.** The clone stays package-less (ADR-0126 §7.1). Nothing changes on the objectstack side. Studio gains a surface for package-less, org-owned flows. Dev run: `session_01FjqrwXPfSMkSfkKYDSRkN2` (`mode:subagent` of the PM seat), branch `claude/issue-11553-studio-packageless-flows`, cut from `origin/main` `d0c0c7fe9`. ## What failed, measured on `origin/main` before the change - Routing: `studioRoutes` declared only `/studio`, `/studio/:packageId` and `/studio/:packageId/:tab`, so every pillar was keyed by a package. - The Automations rail called `loadPackageSurfaces(client, 'flow', packageId)`, which reads `list('flow', { packageId })` and `listDrafts({ packageId, type })`. The registry's package filter keeps only items whose `_packageId` equals that package, so a flow with no package is in no rail. - The home (`BuilderLanding`) listed packages only. On the stock showcase that means "No writable packages yet" plus the read-only showcase package. - The deep link: the rail's list effect did `setCurrent((c) => c ?? deepLinked ?? items[0] ?? null)`. A `?surface=flow:NAME` link naming a flow the rail did not hold therefore opened the first flow, and the URL mirror rewrote `?surface=` to that flow. That is the "redirects to another flow" from the QA run. ## The change **One reserved scope, `/studio/~org/automations`, served by the same `StudioDesignSurface` with no package under it.** There is no second builder. - `studioScope.ts` (new) holds: - the segment `~org`; - the path builder `studioOrgScopePath`; - the package-less predicates. They read the spec's `_packageId` on served items (from `MetadataProtectionFields`, which `FlowSchema` spreads) and `packageId` on `_drafts` headers. The DB-authored `sys_metadata` pseudo-package also reads as "no package", the same reading `studioPackageId` in `utils/appRoute.ts` gives it. - `~` is outside the spec's `MANIFEST_ID_PATTERN` and outside this app's `PACKAGE_ID_RE` and `sanitizePackageId`, so the segment cannot equal a package id. A test pins this against both rules. - `packageSurfaces.ts`: new `loadPackageLessSurfaces`. It does an unscoped `list('flow')` and `listDrafts({ type })`, keeps only rows and headers bound to no package, and merges them through the existing published-wins merge. - `AutomationsPillar`: `packageId: string | null`. With `null` it lists package-less flows, and drafts save with no `package=`. The scope has no "New" flow (authoring stays package-first). - Deep links: a `?surface=flow:` link that names a flow the rail does not hold no longer opens the first flow. From a package's pillar, a named package-less flow is found and opened in `/studio/~org/automations?surface=flow:NAME`, once per mount with replace navigation. A flow found nowhere is reported on the canvas ("The link names flow “NAME”, which is not here."). - `StudioDesignSurface`, in the package-less scope: - Writable by derivation. `readOnly` is `packageId !== null && pkgWritable === false`, so a read-only answer left over from the package shown before the switch cannot lock the scope. The server write gate stays the authority. - Offers its one pillar (another pillar's URL lands on it), no Create app, and no package copilot. - The header count and the pending-changes sheet cover package-less **flow** drafts only. - Publish promotes each of those drafts by reference through `publishDraft`. `POST /packages/:id/publish-drafts` cannot reach a draft bound to no package; this is the same door Home's publish-all uses for package-less drafts. It is not all-or-nothing, and failures are named per draft. - `PackageSwitcher` and `BuilderLanding` each get an entry, "Organization flows". On the home it sits under its own "Not in a package" heading, shown whether or not any writable package exists. - `DraftChangesPanel` gets an optional `include` predicate. The scope passes its own filter, so the sheet reviews exactly what that scope's Publish ships. Package hosts pass nothing, and their behaviour is unchanged. - The console's `studioRoutes` get the bare `/studio/~org` leg, which lands on `automations`. The pillar URL itself is served by the generic `:packageId/:tab` route. - i18n: seven new keys in `metadata-admin/i18n.ts` (en and zh), added beside `engine.studio.landing.installedHeading`. No key is moved or reordered. - Docs: `packages/app-shell/README.md` and `content/docs/guide/console.md`. Changeset: `.changeset/11553-studio-package-less-flows.md` (`@object-ui/app-shell` minor, `@object-ui/console` patch). **Landing versus the claim's file surface.** Everything sits in the claimed surface except three files in the same package, each needed by it: - `packages/app-shell/src/preview/DraftChangesPanel.tsx`: the `include` predicate, so the sheet does not review drafts the scope's Publish never ships; - `packages/app-shell/src/index.ts`: exports the segment, pillar and path builder for the console route; - the two docs files above, which AGENTS.md commandment #2 requires. ## The ruling's pins | Pin | Where | |---|---| | A package-less flow is listed and opens editable | `StudioDesignSurface.packageLessFlows-11553.test.tsx`: "lists the clone and none of the packaged flows"; "opens it editable, and an edit saves a draft bound to no package" | | A packaged flow on a read-only package stays read-only | the same file, "lists the package’s flows, not the clone, and offers no edit"; objectui#11124's `StudioDesignSurface.automationsReadOnly-11124.test.tsx` stays green, unchanged | | The deep link to a package-less flow opens that flow, not another | "in the package-less scope: the named flow, though another is listed first"; "the measured link, through the showcase pillar: lands on the package-less scope, the clone open and editable" | Further pins: `studioScope.test.ts`, `packageSurfaces.test.ts` (`loadPackageLessSurfaces`), `BuilderLanding.orgScope-11553.test.tsx` (the entry on the measured shape, with no writable package) and `StudioRoute.test.tsx` (the `~org` leg, behind the same entry gate). ## Verification (final head `4831c5a`) - Build: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` exited 0 (29 packages). The plugin dists the console needs were built for its type-check. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. This covers both `tsc --noEmit` and `tsconfig.test.json`. - `pnpm --filter @object-ui/console type-check`: exit 0, after rebuilding app-shell. - `pnpm exec vitest run packages/app-shell/src/views/studio-design/`: 88 files, 521 tests passed. - `pnpm exec vitest run packages/app-shell/src/preview/ apps/console/src/components/`: 50 files, 562 tests passed. - `eslint` on the 13 changed TS/TSX files: exit 0, 0 errors. Warnings per file equal the base `d0c0c7fe9` count for each pre-existing file (StudioDesignSurface 17, DraftChangesPanel 2, StudioRoute 1, BuilderLanding 0). - Repo checks, all exit 0: - `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:i18n-designer-parity` - `check:control-bytes`, `check:new-line-citations`, `check:changeset-claims`, `check:pending-changeset-literals` - `check:spec-symbols`, `check:readme-exports`, `check:unreferenced-sources`, `check:test-path-roots` - `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape` - `check:metadata-write-doors`, `check:handler-key-reads`, `check:self-import`, `check:phantom-deps`, `check:esm-specifiers` - `check:doc-fences`, `check:doc-types` - `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-overwrite`, `check-changeset-fixed`, `check-doc-links` **Ablations.** Each mutation was made on committed code through `ablation-replace.mjs` in WRAP mode. Each anchor hit once, the blob changed, and the restore was proven with blob equal to HEAD and an empty `git diff HEAD`. The tests import `./StudioDesignSurface` from source, so no build was needed. | Ablation | Result | |---|---| | A1: the deep-link fallback put back to `?? items[0]` | 1 red: "a link naming a flow no rail holds opens no other flow" | | A2: the package-less redirect removed | 1 red: "the measured link, through the showcase pillar" | | A3: the package-less loader swapped for the package loader | 9 red | | A4: the package-less publish arm disabled | 1 red: "publishes each package-less flow draft by reference" | | A5: the header count unfiltered | 1 red: "counts only package-less flow drafts" | | A6: the sheet's `include` dropped | 1 red | | A7: `readOnly` put back to `pkgWritable === false` (at `4831c5a`) | 2 red, including "from that read-only package, the switcher’s entry opens the package-less scope editable" | Lint was narrowed to the changed files. The repo-wide `pnpm lint` and the full test shards are left to CI. No browser check was run. ## Acceptance notes - **Not this card:** objectui#11546 (a read-only canvas opens no node inspector) remains open; this change does not touch it. - **The Setup page's clone result does not link to the copy in Studio.** Setup › Packaged automation says "Edit the copy in Studio" after a clone. The copy is now reachable from Studio's home and switcher, and `studioOrgScopePath({ type: 'flow', name })` could produce a direct link, but none is added here (that file is outside this card). Noted, not filed; carrier: none. - **The same deep-link fallback lives in two sibling rails.** The Interfaces rail (`cur ?? deepLinked ?? firstLeaf`) and the Data rail (`c ?? deepLinked ?? items[0]`) still open their first item when a `?surface=` link names one they do not hold. That is the same mechanism this card removed for flows, but it was not measured through a public entry here. Noted, not filed; carrier: none. - **A host that mounts `StudioDesignSurface` with its own `aiSlot`** (the cloud edition) and reads `:packageId` itself would see `~org` as a package id in this scope. Not measured: there is no such host in this repository. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b0bf413 commit f624f27

16 files changed

Lines changed: 1109 additions & 42 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/console': patch
4+
---
5+
6+
Studio reaches the organization's own flows that belong to no package (objectui#11553).
7+
8+
A clone of a packaged flow is, by ADR-0126 §7.1, an ordinary org-owned flow, and the clone door stores it with no package on purpose. Studio was routed and listed per package, so the clone matched no route and no rail: the Studio home said "No writable packages yet", the read-only package's Automations rail listed only the packaged flows, and a deep link naming the clone opened another flow.
9+
10+
- **A package-less scope, `/studio/~org/automations`.** The same Studio design surface with no package under it. Its Automations rail lists every flow that belongs to no package (the unscoped flow list, narrowed by each item's owning-package field, plus package-less drafts), and opens each one editable. Edits save as drafts bound to no package; the header's count, the pending-changes sheet and Publish cover package-less flow drafts only, and Publish promotes each one by reference, since the package batch door cannot reach a draft bound to no package. The scope offers no other pillar, no "New" flow, no Create app and no package copilot. `~` is outside every package-id alphabet, so the segment can never name a package.
11+
- **Reachable from Studio's home and from the package switcher**, whether or not a writable package exists.
12+
- **A deep link names the flow it opens.** A `?surface=flow:` link naming a flow the rail does not hold no longer opens the rail's first flow in its place: from a package's pillar, a package-less flow is found and opened in the package-less scope; a flow found nowhere is reported on the canvas.
13+
- A read-only package's flows stay read-only, as before.
14+
- The console's `/studio` routes gain the scope's bare leg, `/studio/~org`, which lands on its one pillar.

‎apps/console/src/components/StudioRoute.test.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,25 @@ describe('/studio/* — the entry decision, both ways', () => {
336336
);
337337
});
338338

339+
it('the package-less scope\'s bare leg lands on its one pillar, the builder mounted (objectui#11553)', async () => {
340+
// `~org` is the reserved segment for the organization's own, package-less
341+
// flows. Without its own leg it falls to `:packageId`, whose redirect
342+
// targets a Data pillar that scope does not have.
343+
answerWith(OPERATOR_CAPS);
344+
renderStudioDeepLink('/studio/~org');
345+
346+
await waitFor(() => expect(pathname()).toBe('/studio/~org/automations'));
347+
await waitFor(() => expect(screen.getByTestId('studio-pillar-builder')).toBeInTheDocument());
348+
expect(designSurface).toHaveBeenCalled();
349+
});
350+
351+
it('the package-less scope is behind the same entry gate (objectui#11553)', async () => {
352+
renderStudioDeepLink('/studio/~org/automations');
353+
354+
await waitFor(() => expect(pathname()).toBe('/home'));
355+
expect(designSurface).not.toHaveBeenCalled();
356+
});
357+
339358
it('NEGATIVE CONTROL: the holder is answered ONCE for the whole subtree', async () => {
340359
answerWith(OPERATOR_CAPS);
341360
renderStudioDeepLink('/studio/hotcrm/data');

‎apps/console/src/components/StudioRoute.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import {
4040
BuilderLanding,
4141
LoadingFallback,
4242
LoadingScreen,
43+
STUDIO_ORG_SCOPE_PILLAR,
44+
STUDIO_ORG_SCOPE_SEGMENT,
4345
StudioDesignSurface,
4446
getProductName,
4547
useHomePath,
@@ -102,7 +104,8 @@ export function StudioRoute() {
102104
}
103105

104106
/**
105-
* The `/studio` front door: pick or create a writable package.
107+
* The `/studio` front door: pick or create a writable package, or open the
108+
* organization's package-less flows.
106109
*
107110
* Standalone frame — the landing must never be a navigation dead end, so the
108111
* wordmark walks back to the platform Home.
@@ -150,12 +153,25 @@ function StudioLanding() {
150153
* agree with itself while `App.tsx` quietly mounted the builder ungated.
151154
*
152155
* `/studio` front door (pick / create a writable package)
156+
* `/studio/~org` the package-less scope lands on its one pillar
153157
* `/studio/:packageId` a package lands on its Data pillar
154158
* `/studio/:packageId/:tab` the pillar builder
159+
*
160+
* `~org` is the reserved segment for the organization's own, package-less
161+
* flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never
162+
* name a package). Its pillar URL is served by the generic `:packageId/:tab`
163+
* route — the SAME `StudioDesignSurface`, which reads the segment as "no
164+
* package" — so only its bare leg needs a route of its own: a static segment
165+
* outranks `:packageId`, and the generic leg would send it to a Data pillar the
166+
* scope does not have.
155167
*/
156168
export const studioRoutes = (
157169
<Route path="/studio" element={<StudioRoute />}>
158170
<Route index element={<StudioLanding />} />
171+
<Route
172+
path={STUDIO_ORG_SCOPE_SEGMENT}
173+
element={<Navigate to={STUDIO_ORG_SCOPE_PILLAR} replace />}
174+
/>
159175
<Route path=":packageId" element={<Navigate to="data" replace />} />
160176
<Route path=":packageId/:tab" element={<StudioDesignSurface />} />
161177
</Route>

‎content/docs/guide/console.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
2929
| **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. |
3030
| **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. |
3131
| **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. |
32+
| **Package-less Flows in Studio** | Flows that belong to no package — such as a clone of a packaged flow, made from Setup › Packaged automation — are listed and edited at `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the package switcher. They open editable, edits save as drafts, and Publish promotes those drafts. |
3233
| **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=<type>:<name>`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. |
3334
| **Dashboard Refresh** | A dashboard page shows a **Refresh All** button above its widgets, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted. `0` or no value means no automatic refresh. Known gap: a dataset-bound single-value (KPI) tile does not refresh yet. |
3435
| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. |

‎packages/app-shell/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,22 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
374374
The Studio sidebar also flattens the root Overview group so Home and package
375375
navigation sit directly under the package selector.
376376

377+
### Package-less flows (`/studio/~org/automations`)
378+
379+
A flow that belongs to no package — a clone of a packaged flow is one, by
380+
ADR-0126 §7.1 — matches no package scope, so Studio gives it one scope of its
381+
own: `/studio/~org/automations` (`studioOrgScopePath()`, segment
382+
`STUDIO_ORG_SCOPE_SEGMENT`). It is the same `StudioDesignSurface` with no
383+
package under it, reached from the Studio home and from the package switcher.
384+
Its Automations rail lists every flow whose served `_packageId` names no
385+
package, opens each one editable, and saves package-less drafts; its Publish
386+
promotes those drafts one by one through the single-item publish door, because
387+
the package batch publish cannot reach a draft bound to no package. It offers
388+
no other pillar and no "New" flow. `~` is outside every package-id alphabet,
389+
so the segment can never name a package. A `?surface=flow:` deep link that
390+
names a flow the open rail does not hold opens no other flow in its place;
391+
from a package's pillar, a package-less flow is opened in this scope instead.
392+
377393
### Access matrix (package-scoped)
378394

379395
The Access pillar's permission matrix follows the active package (ADR-0086 P0).

‎packages/app-shell/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,14 @@ export type {
451451
// The builder's front door: pick/create a writable package → pillar builder.
452452
// Standalone at `/studio` and embedded via the `studio:builder` component ref.
453453
export { BuilderLanding } from './views/studio-design/BuilderLanding.js';
454+
// The one Studio scope that is not a package (objectui#11553): the
455+
// organization's own package-less flows, at `/studio/~org/automations`. A host
456+
// that declares the `/studio` routes reads the reserved segment from here.
457+
export {
458+
STUDIO_ORG_SCOPE_SEGMENT,
459+
STUDIO_ORG_SCOPE_PILLAR,
460+
studioOrgScopePath,
461+
} from './views/studio-design/studioScope.js';
454462

455463
// Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows
456464
// installed packages ship. Reached through the `automation:packaged` component

‎packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,14 @@ export interface DraftChangesPanelProps {
337337
onOpenChange: (open: boolean) => void;
338338
/** When set, list only pending drafts belonging to this package (Studio is package-scoped). */
339339
packageId?: string | null;
340+
/**
341+
* Narrows the listed drafts further, for a host whose scope the `_drafts`
342+
* query cannot express on its own. Studio's package-less scope passes its
343+
* own predicate (package-less FLOW drafts, objectui#11553) so the sheet
344+
* reviews exactly what that scope's Publish ships. Applied to the folded
345+
* entries, before anything is classified or linted.
346+
*/
347+
include?: (entry: DraftChangeEntry) => boolean;
340348
/**
341349
* When provided, the panel renders a confirm footer whose button invokes
342350
* this — turning the panel into the review-then-publish step. The caller
@@ -351,6 +359,7 @@ export function DraftChangesPanel({
351359
open,
352360
onOpenChange,
353361
packageId,
362+
include,
354363
onPublish,
355364
publishing = false,
356365
}: DraftChangesPanelProps) {
@@ -386,7 +395,8 @@ export function DraftChangesPanel({
386395
setError(null);
387396
setProblems([]);
388397
try {
389-
const drafts = await listPendingDrafts(packageId);
398+
const listed = await listPendingDrafts(packageId);
399+
const drafts = include ? listed.filter(include) : listed;
390400
setEntries(drafts);
391401
// Mirror the publish door's own security-posture rule over these drafts.
392402
// Deliberately not awaited with the classification below: a finding is
@@ -428,7 +438,7 @@ export function DraftChangesPanel({
428438
} catch (e) {
429439
setError((e as Error).message);
430440
}
431-
}, [packageId]);
441+
}, [packageId, include]);
432442

433443
useEffect(() => {
434444
if (open) void load();

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2776,6 +2776,14 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
27762776
'engine.studio.landing.dupGo': 'Duplicate and open the builder',
27772777
'engine.studio.landing.dupCreated': 'Duplicated into writable package “{name}”',
27782778
'engine.studio.landing.installedHeading': 'Installed (read-only · browsable)',
2779+
// objectui#11553 — the package-less scope: the organization's own flows.
2780+
'engine.studio.landing.orgHeading': 'Not in a package',
2781+
'engine.studio.org.name': 'Organization flows',
2782+
'engine.studio.org.hint': 'Flows that belong to no package',
2783+
'engine.studio.org.description': 'The organization’s own flows that belong to no package, such as a clone of a packaged flow. They open editable.',
2784+
'engine.studio.org.none': 'No flows outside a package yet. Clone a packaged flow in Setup › Packaged automation to edit the copy here.',
2785+
'engine.studio.org.published': 'Published the package-less flow drafts',
2786+
'engine.studio.auto.deepLinkMissing': 'The link names flow “{name}”, which is not here.',
27792787
'engine.studio.designer.search': 'Search…',
27802788
'engine.studio.designer.select': 'Select…',
27812789
'engine.studio.designer.pickDate': 'Pick a date…',
@@ -5649,6 +5657,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
56495657
'engine.studio.landing.dupGo': '复制并进入构建器',
56505658
'engine.studio.landing.dupCreated': '已复制为可写软件包「{name}」',
56515659
'engine.studio.landing.installedHeading': '已安装(只读 · 可浏览)',
5660+
// objectui#11553 — the package-less scope: the organization's own flows.
5661+
'engine.studio.landing.orgHeading': '不属于软件包',
5662+
'engine.studio.org.name': '组织流程',
5663+
'engine.studio.org.hint': '不属于任何软件包的流程',
5664+
'engine.studio.org.description': '本组织自有、不属于任何软件包的流程,例如软件包流程的克隆副本。可直接编辑。',
5665+
'engine.studio.org.none': '还没有不属于软件包的流程。在 设置 › 打包自动化 中克隆一个软件包流程,即可在这里编辑副本。',
5666+
'engine.studio.org.published': '已发布不属于软件包的流程草稿',
5667+
'engine.studio.auto.deepLinkMissing': '链接指向的流程「{name}」不在这里。',
56525668
'engine.studio.designer.search': '搜索…',
56535669
'engine.studio.designer.select': '请选择…',
56545670
'engine.studio.designer.pickDate': '选择日期…',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11553 — Studio's home reaches the organization's package-less flows.
5+
*
6+
* Measured on the stock showcase: the home said "No writable packages yet" and
7+
* its only entry was the read-only showcase package, whose Automations rail
8+
* lists packaged flows and not the clone. A package-less flow matches no
9+
* package card, so the home carries an entry of its own for them, and it is
10+
* there whether or not a writable package exists.
11+
*/
12+
13+
import '@testing-library/jest-dom/vitest';
14+
import * as React from 'react';
15+
import { afterEach, describe, expect, it, vi } from 'vitest';
16+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
17+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
18+
19+
const packages = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
20+
21+
vi.mock('./packages-io', async (importOriginal) => {
22+
const mod = await importOriginal<typeof import('./packages-io')>();
23+
return { ...mod, fetchPackages: vi.fn(async () => packages.list) };
24+
});
25+
26+
// The create dialog is closed throughout; its form stack is not under test.
27+
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
28+
29+
import { BuilderLanding } from './BuilderLanding';
30+
31+
afterEach(() => {
32+
cleanup();
33+
packages.list = [];
34+
});
35+
36+
function LocationProbe() {
37+
const { pathname } = useLocation();
38+
return <div data-testid="location">{pathname}</div>;
39+
}
40+
41+
function renderLanding() {
42+
return render(
43+
<MemoryRouter initialEntries={['/studio']}>
44+
<LocationProbe />
45+
<Routes>
46+
<Route path="/studio" element={<BuilderLanding />} />
47+
<Route path="/studio/:packageId/:tab" element={<div data-testid="pillar-builder" />} />
48+
</Routes>
49+
</MemoryRouter>,
50+
);
51+
}
52+
53+
describe('Studio home → the package-less scope (objectui#11553)', () => {
54+
it('offers the entry on the measured shape: no writable package, one read-only package', async () => {
55+
packages.list = [{ id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' }];
56+
renderLanding();
57+
58+
await screen.findByText('No writable packages yet — create one to start.');
59+
const entry = screen.getByTestId('studio-landing-org-scope');
60+
expect(entry).toHaveTextContent('Organization flows');
61+
expect(screen.getByText('Not in a package')).toBeInTheDocument();
62+
63+
fireEvent.click(entry);
64+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/~org/automations'));
65+
expect(screen.getByTestId('pillar-builder')).toBeInTheDocument();
66+
});
67+
68+
it('offers it beside writable packages too, and a package card still opens that package', async () => {
69+
packages.list = [{ id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }];
70+
renderLanding();
71+
72+
await screen.findByText('Acme');
73+
expect(screen.getByTestId('studio-landing-org-scope')).toBeInTheDocument();
74+
75+
fireEvent.click(screen.getByText('Acme'));
76+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/com.acme.app/data'));
77+
});
78+
});

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@
99
* (`/studio/:packageId/:tab`). Also served standalone at bare `/studio` so the
1010
* builder is bookmarkable.
1111
*
12-
* Writable bases (where authoring happens) lead; read-only code packages are
13-
* listed secondary for browsing. Writability is the shared display heuristic
12+
* Writable bases (where authoring happens) lead; the organization's own
13+
* package-less flows have one entry of their own (objectui#11553); read-only
14+
* code packages are listed secondary for browsing. Writability is the shared display heuristic
1415
* from packages-io — the ADR-0070 D4 gate stays the server-side authority.
1516
*/
1617

1718
import * as React from 'react';
1819
import { useNavigate } from 'react-router-dom';
19-
import { Boxes, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react';
20+
import { Boxes, Building2, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react';
2021
import { toast } from 'sonner';
2122
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
2223
import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js';
2324
import { fetchPackages, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io.js';
2425
import { PackageIdInput } from './PackageIdInput.js';
26+
import { studioOrgScopePath } from './studioScope.js';
2527

2628
export function BuilderLanding(): React.ReactElement {
2729
const navigate = useNavigate();
@@ -203,6 +205,32 @@ export function BuilderLanding(): React.ReactElement {
203205
onSaved={(r) => open(r.id)}
204206
/>
205207

208+
{/* objectui#11553 — the organization's own flows, which belong to no
209+
* package (a clone of a packaged flow is one, by ADR-0126 §7.1). They
210+
* match no package card above, so they get their own entry, shown
211+
* whether or not any writable package exists. */}
212+
<h2 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
213+
{t('engine.studio.landing.orgHeading', locale)}
214+
</h2>
215+
<div className="mb-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
216+
<button
217+
type="button"
218+
data-testid="studio-landing-org-scope"
219+
onClick={() => navigate(studioOrgScopePath())}
220+
className="flex items-center gap-2.5 rounded-lg border bg-background px-3 py-2.5 text-left hover:bg-muted/40"
221+
>
222+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
223+
<Building2 className="h-4 w-4" />
224+
</span>
225+
<span className="min-w-0 flex-1">
226+
<span className="block truncate text-[13px] font-medium">{t('engine.studio.org.name', locale)}</span>
227+
<span className="block text-[10px] leading-4 text-muted-foreground">
228+
{t('engine.studio.org.description', locale)}
229+
</span>
230+
</span>
231+
</button>
232+
</div>
233+
206234
{readonly.length > 0 && (
207235
<>
208236
<h2 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">

0 commit comments

Comments
 (0)