From 3e0dd09dda2fcb751d579c275c01ab5a460b203e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:32:18 +0000 Subject: [PATCH 1/6] feat(app-shell,console): Studio scope for package-less flows at /studio/~org/automations (objectui#11553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clone of a packaged flow is correctly package-less (ADR-0126 §7.1), and every Studio route and rail was keyed by a package, so the clone reached no Studio surface. Studio gains one reserved scope, served by the same StudioDesignSurface with no package under it: - studioScope.ts: the reserved `~org` segment (outside every package-id alphabet), the path builder, and the package-less predicates read off the spec's `_packageId` (served items) and the draft header's `packageId`. - packageSurfaces.ts: loadPackageLessSurfaces, the unscoped list and drafts narrowed to items bound to no package, through the shared merge. - AutomationsPillar: `packageId: null` lists package-less flows and saves package-less drafts; no "New" there. A deep link naming a flow the rail does not hold no longer opens the first flow instead; from a package's pillar a package-less flow is found and opened in the package-less scope. - StudioDesignSurface: the package-less scope is writable, offers only the Automations pillar, no Create app and no package copilot; its header counts and its Publish ships package-less flow drafts by reference. - PackageSwitcher and BuilderLanding: an entry for the scope, shown whether or not a writable package exists. - DraftChangesPanel: an `include` predicate so the scope reviews what it ships. - console studioRoutes: the bare `/studio/~org` leg lands on its pillar. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- apps/console/src/components/StudioRoute.tsx | 18 +- packages/app-shell/src/index.ts | 8 + .../src/preview/DraftChangesPanel.tsx | 14 +- .../src/views/metadata-admin/i18n.ts | 16 ++ .../views/studio-design/BuilderLanding.tsx | 34 ++- .../studio-design/StudioDesignSurface.tsx | 255 +++++++++++++++--- .../views/studio-design/packageSurfaces.ts | 42 ++- .../src/views/studio-design/studioScope.ts | 103 +++++++ 8 files changed, 451 insertions(+), 39 deletions(-) create mode 100644 packages/app-shell/src/views/studio-design/studioScope.ts diff --git a/apps/console/src/components/StudioRoute.tsx b/apps/console/src/components/StudioRoute.tsx index 49449c46f7..72fcc402ed 100644 --- a/apps/console/src/components/StudioRoute.tsx +++ b/apps/console/src/components/StudioRoute.tsx @@ -40,6 +40,8 @@ import { BuilderLanding, LoadingFallback, LoadingScreen, + STUDIO_ORG_SCOPE_PILLAR, + STUDIO_ORG_SCOPE_SEGMENT, StudioDesignSurface, getProductName, useHomePath, @@ -102,7 +104,8 @@ export function StudioRoute() { } /** - * The `/studio` front door: pick or create a writable package. + * The `/studio` front door: pick or create a writable package, or open the + * organization's package-less flows. * * Standalone frame — the landing must never be a navigation dead end, so the * wordmark walks back to the platform Home. @@ -150,12 +153,25 @@ function StudioLanding() { * agree with itself while `App.tsx` quietly mounted the builder ungated. * * `/studio` front door (pick / create a writable package) + * `/studio/~org` the package-less scope lands on its one pillar * `/studio/:packageId` a package lands on its Data pillar * `/studio/:packageId/:tab` the pillar builder + * + * `~org` is the reserved segment for the organization's own, package-less + * flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never + * name a package). Its pillar URL is served by the generic `:packageId/:tab` + * route — the SAME `StudioDesignSurface`, which reads the segment as "no + * package" — so only its bare leg needs a route of its own: a static segment + * outranks `:packageId`, and the generic leg would send it to a Data pillar the + * scope does not have. */ export const studioRoutes = ( }> } /> + } + /> } /> } /> diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index e6c25b6fa7..6246d77840 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -451,6 +451,14 @@ export type { // The builder's front door: pick/create a writable package → pillar builder. // Standalone at `/studio` and embedded via the `studio:builder` component ref. export { BuilderLanding } from './views/studio-design/BuilderLanding.js'; +// The one Studio scope that is not a package (objectui#11553): the +// organization's own package-less flows, at `/studio/~org/automations`. A host +// that declares the `/studio` routes reads the reserved segment from here. +export { + STUDIO_ORG_SCOPE_SEGMENT, + STUDIO_ORG_SCOPE_PILLAR, + studioOrgScopePath, +} from './views/studio-design/studioScope.js'; // Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows // installed packages ship. Reached through the `automation:packaged` component diff --git a/packages/app-shell/src/preview/DraftChangesPanel.tsx b/packages/app-shell/src/preview/DraftChangesPanel.tsx index 2eeeb4a051..56532f29f8 100644 --- a/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -337,6 +337,14 @@ export interface DraftChangesPanelProps { onOpenChange: (open: boolean) => void; /** When set, list only pending drafts belonging to this package (Studio is package-scoped). */ packageId?: string | null; + /** + * Narrows the listed drafts further, for a host whose scope the `_drafts` + * query cannot express on its own. Studio's package-less scope passes its + * own predicate (package-less FLOW drafts, objectui#11553) so the sheet + * reviews exactly what that scope's Publish ships. Applied to the folded + * entries, before anything is classified or linted. + */ + include?: (entry: DraftChangeEntry) => boolean; /** * When provided, the panel renders a confirm footer whose button invokes * this — turning the panel into the review-then-publish step. The caller @@ -351,6 +359,7 @@ export function DraftChangesPanel({ open, onOpenChange, packageId, + include, onPublish, publishing = false, }: DraftChangesPanelProps) { @@ -386,7 +395,8 @@ export function DraftChangesPanel({ setError(null); setProblems([]); try { - const drafts = await listPendingDrafts(packageId); + const listed = await listPendingDrafts(packageId); + const drafts = include ? listed.filter(include) : listed; setEntries(drafts); // Mirror the publish door's own security-posture rule over these drafts. // Deliberately not awaited with the classification below: a finding is @@ -428,7 +438,7 @@ export function DraftChangesPanel({ } catch (e) { setError((e as Error).message); } - }, [packageId]); + }, [packageId, include]); useEffect(() => { if (open) void load(); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 4026b9aa61..3ab83b8986 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2776,6 +2776,14 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.landing.dupGo': 'Duplicate and open the builder', 'engine.studio.landing.dupCreated': 'Duplicated into writable package “{name}”', 'engine.studio.landing.installedHeading': 'Installed (read-only · browsable)', + // objectui#11553 — the package-less scope: the organization's own flows. + 'engine.studio.landing.orgHeading': 'Not in a package', + 'engine.studio.org.name': 'Organization flows', + 'engine.studio.org.hint': 'Flows that belong to no package', + '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.', + 'engine.studio.org.none': 'No flows outside a package yet. Clone a packaged flow in Setup › Packaged automation to edit the copy here.', + 'engine.studio.org.published': 'Published the package-less flow drafts', + 'engine.studio.auto.deepLinkMissing': 'The link names flow “{name}”, which is not here.', 'engine.studio.designer.search': 'Search…', 'engine.studio.designer.select': 'Select…', 'engine.studio.designer.pickDate': 'Pick a date…', @@ -5649,6 +5657,14 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.landing.dupGo': '复制并进入构建器', 'engine.studio.landing.dupCreated': '已复制为可写软件包「{name}」', 'engine.studio.landing.installedHeading': '已安装(只读 · 可浏览)', + // objectui#11553 — the package-less scope: the organization's own flows. + 'engine.studio.landing.orgHeading': '不属于软件包', + 'engine.studio.org.name': '组织流程', + 'engine.studio.org.hint': '不属于任何软件包的流程', + 'engine.studio.org.description': '本组织自有、不属于任何软件包的流程,例如软件包流程的克隆副本。可直接编辑。', + 'engine.studio.org.none': '还没有不属于软件包的流程。在 设置 › 打包自动化 中克隆一个软件包流程,即可在这里编辑副本。', + 'engine.studio.org.published': '已发布不属于软件包的流程草稿', + 'engine.studio.auto.deepLinkMissing': '链接指向的流程「{name}」不在这里。', 'engine.studio.designer.search': '搜索…', 'engine.studio.designer.select': '请选择…', 'engine.studio.designer.pickDate': '选择日期…', diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index c8df436859..11335a6a50 100644 --- a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -9,19 +9,21 @@ * (`/studio/:packageId/:tab`). Also served standalone at bare `/studio` so the * builder is bookmarkable. * - * Writable bases (where authoring happens) lead; read-only code packages are - * listed secondary for browsing. Writability is the shared display heuristic + * Writable bases (where authoring happens) lead; the organization's own + * package-less flows have one entry of their own (objectui#11553); read-only + * code packages are listed secondary for browsing. Writability is the shared display heuristic * from packages-io — the ADR-0070 D4 gate stays the server-side authority. */ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; -import { Boxes, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react'; +import { Boxes, Building2, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react'; import { toast } from 'sonner'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js'; import { fetchPackages, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io.js'; import { PackageIdInput } from './PackageIdInput.js'; +import { studioOrgScopePath } from './studioScope.js'; export function BuilderLanding(): React.ReactElement { const navigate = useNavigate(); @@ -203,6 +205,32 @@ export function BuilderLanding(): React.ReactElement { onSaved={(r) => open(r.id)} /> + {/* objectui#11553 — the organization's own flows, which belong to no + * package (a clone of a packaged flow is one, by ADR-0126 §7.1). They + * match no package card above, so they get their own entry, shown + * whether or not any writable package exists. */} +

+ {t('engine.studio.landing.orgHeading', locale)} +

+
+ +
+ {readonly.length > 0 && ( <>

diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index d9379c63fe..8bf1bb4982 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link, Navigate } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider } from '@object-ui/react'; // The ONE draft-envelope reader (objectui#8181): unwrap AND strip the // framework's read decorations in one place. This file used to carry its own @@ -50,6 +50,7 @@ import { AlertTriangle, Ban, Boxes, + Building2, FileText, Database, LayoutDashboard, @@ -102,11 +103,17 @@ import { } from '../metadata-admin/nav-selection.js'; import { useNavSelDeepLink } from '../metadata-admin/useNavSelDeepLink.js'; import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js'; -import { usePendingDrafts } from '../../preview/usePendingDrafts.js'; +import { fetchPendingDrafts, usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; import { formatPublishFailures, type PublishFailure } from './metadataError.js'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; -import { loadPackageSurfaces } from './packageSurfaces.js'; +import { loadPackageLessSurfaces, loadPackageSurfaces } from './packageSurfaces.js'; +import { + STUDIO_ORG_SCOPE_PILLAR, + isOrgScopeDraft, + isStudioOrgScope, + studioOrgScopePath, +} from './studioScope.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; import { useHomePath } from '../../hooks/useHomePath.js'; import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; @@ -407,7 +414,8 @@ function PackageSwitcher({ tab, beforeNavigate, }: { - packageId: string; + /** The open package, or `null` in the package-less scope (objectui#11553). */ + packageId: string | null; tab: string; /** objectui#2600 — veto hook for package-switch navigation: return false to * stay put (the surface prompts about unsaved pillar edits). Not consulted @@ -791,7 +799,17 @@ function PackageSwitcher({ className="flex items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[13px] font-medium hover:bg-muted" title={t('engine.studio.pkg.switchTitle', locale)} > - {current?.name ?? packageId} + {/* objectui#11553 — the package-less scope names itself; there is + no package id to show as the diagnostic handle. */} + {packageId === null ? ( + <> + {t('engine.studio.org.name', locale)} + + ) : ( + <> + {current?.name ?? packageId} + + )} {/* ⛔ Not a replacement for the id (objectui#7368): the id is the one diagnostic handle the author has, so the states are told apart by what stands NEXT to it, never by swapping it for prose. */} @@ -877,11 +895,39 @@ function PackageSwitcher({ ))} + {/* objectui#11553 — the package-less scope, reachable from every + package as it is from the Studio home. It lands on its one + pillar whatever pillar is open here. */} +
+ +
+
{current && (
· {/* Package-level draft review + one atomic publish (replaces per-item Publish) */}
{/* objectui#5800 — the Open app teleport is retired: the canvas's Run mode IS the way to try the app without leaving the - workbench. The published-app state needs no chrome at all. */} - {packageApp ? null : appDraftPending ? ( + workbench. The published-app state needs no chrome at all. + objectui#11553 — the package-less scope has no app to create. */} + {packageId === null || packageApp ? null : appDraftPending ? ( + ) : ( + + ) + ) : tab === 'data' ? ( )} + {/* objectui#11553 — the package-less scope reviews exactly what its + * Publish ships: package-less flow drafts, read off the env-wide + * feed (`packageId` null) and narrowed by `include`. */} @@ -4391,7 +4531,12 @@ export function AutomationsPillar({ onDraftSaved, readOnly = false, }: { - packageId: string; + /** + * The package whose flows the rail lists, or `null` for the package-less + * scope (objectui#11553, `studioScope.ts`): every flow that belongs to no + * package, saved as package-less drafts. + */ + packageId: string | null; publishNonce?: number; onDraftSaved?: () => void; /** Courtesy gate: hide/disable flow-authoring affordances. */ @@ -4399,6 +4544,11 @@ export function AutomationsPillar({ }): React.ReactElement { const client = useMetadataClient(); const locale = useMetadataLocale(); + const navigate = useNavigate(); + // The draft writes' package binding (objectui#11553): none in the + // package-less scope, so the draft row stays as package-less as the flow it + // edits. The client sends no `package=` for an absent id. + const draftPackageId = packageId ?? undefined; // See DataPillar's rail — same mobile-overlay treatment for the flow list. const isMobile = useIsMobile(); const [railOpen, setRailOpen] = React.useState(false); @@ -4411,6 +4561,9 @@ export function AutomationsPillar({ // useSurfaceDeepLink). No producer emits this link yet; honoring it keeps // the pillars uniform so a future "design this flow" bridge just works. const initialSurface = useSurfaceDeepLink(current); + // objectui#11553 — the package-less probe for a deep-linked flow this + // package does not hold runs once per mount, not on every list re-read. + const deepLinkProbedRef = React.useRef(false); const [draft, setDraft] = React.useState>({}); // objectui#11272 — the flow `draft` was loaded for, `flow:NAME`: written // where the load below installs it, and nowhere else. @@ -4423,6 +4576,11 @@ export function AutomationsPillar({ // Tells "still fetching the list" apart from "fetched, package has no flows" // — without it the empty rail showed an endless "Loading…" for a fresh package. const [listed, setListed] = React.useState(false); + // The flow a deep link named that this rail, once listed, does not hold. + const missingFlow = + listed && initialSurface?.type === 'flow' && !flows.some((f) => f.name === initialSurface.name) + ? initialSurface.name + : null; // Inline create — a fresh package starts with zero flows, so the pillar must // offer a way to author the first one (mirrors the object/app creators). const [creating, setCreating] = React.useState(false); @@ -4477,11 +4635,29 @@ export function AutomationsPillar({ // draft exists. Mirrors the Data / Interfaces / Access pillars, which all // merge their drafts. Keyed on `publishNonce` too so drafts that go live // collapse back into the published rail after a package publish. - const items = await loadPackageSurfaces(client, 'flow', packageId); + const items = + packageId === null + ? await loadPackageLessSurfaces(client, 'flow') + : await loadPackageSurfaces(client, 'flow', packageId); if (cancelled) return; setFlows(items); const deepLinked = resolveSurfaceDeepLink(items, initialSurface, 'flow'); - setCurrent((c) => c ?? deepLinked ?? items[0] ?? null); + // objectui#11553 — a deep link that NAMES a flow this rail does not + // hold opens no other flow in its place: the first flow under the name + // of the one asked for is how the clone's link "redirected to another + // flow". A package-less flow named from a package's pillar is found in + // the package-less scope and opened there, once per mount. + const named = initialSurface?.type === 'flow' ? initialSurface.name : null; + if (named && !deepLinked && packageId !== null && !deepLinkProbedRef.current) { + deepLinkProbedRef.current = true; + const packageLess = await loadPackageLessSurfaces(client, 'flow').catch(() => []); + if (cancelled) return; + if (packageLess.some((f) => f.name === named)) { + navigate(studioOrgScopePath({ type: 'flow', name: named }), { replace: true }); + return; + } + } + setCurrent((c) => c ?? deepLinked ?? (named ? null : items[0]) ?? null); } catch (e) { if (!cancelled) setError(formatMetadataError(e)); } finally { @@ -4506,7 +4682,7 @@ export function AutomationsPillar({ t('engine.studio.auto.nodeStart', locale), t('engine.studio.auto.nodeEnd', locale), ); - await client.save('flow', name, skeleton, { mode: 'draft', packageId }); + await client.save('flow', name, skeleton, { mode: 'draft', packageId: draftPackageId }); const item: Surface = { type: 'flow', name, label }; setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); setCurrent(item); @@ -4520,7 +4696,7 @@ export function AutomationsPillar({ setCreateBusy(false); } }, - [client, packageId, onDraftSaved, locale], + [client, draftPackageId, onDraftSaved, locale], ); React.useEffect(() => { @@ -4577,7 +4753,7 @@ export function AutomationsPillar({ setSaving('draft'); setError(null); try { - await client.save('flow', current.name, draft, { mode: 'draft', packageId }); + await client.save('flow', current.name, draft, { mode: 'draft', packageId: draftPackageId }); setHasDraft(true); // objectui#11204 — clean only if nothing was edited while it was in flight. if (sent.unmoved()) setAutoDirty(false); @@ -4587,7 +4763,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [client, current, draft, onDraftSaved]); + }, [client, current, draft, draftPackageId, onDraftSaved]); const { sending: sendingFlowDraft, loaded: flowLoaded } = useDraftAutoSave({ // objectui#11232 — the flow `doSave` addresses. target: `flow:${current?.name ?? ''}`, @@ -4626,7 +4802,7 @@ export function AutomationsPillar({ setSaving('draft'); setError(null); try { - await client.save('flow', flowName, nextDraft, { mode: 'draft', packageId }); + await client.save('flow', flowName, nextDraft, { mode: 'draft', packageId: draftPackageId }); setHasDraft(true); onDraftSaved?.(); toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale)); @@ -4645,7 +4821,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [client, current, draft, packageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); + }, [client, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); return (
@@ -4716,7 +4892,10 @@ export function AutomationsPillar({ >

{t('engine.studio.auto.heading', locale)}

- {!readOnly && ( + {/* objectui#11553 — no "New" in the package-less scope: new + authoring stays package-first, and this scope reaches flows + that already exist without a package. */} + {!readOnly && packageId !== null && (