Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/11863-studio-header.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader` with `variant="studio"`: the brand logo, then a fixed "Studio" crumb, then the header's right-hand cluster as on Home and the Workspaces page (the inbox, help, and the account menu with profile, theme, language and sign-out). The brand links to the declared landing (`useHomePath()`), as the wordmark did, so the landing and the Studio builder's Home button still name one home. No command palette is mounted on `/studio`, so the header shows no search trigger there.

**Note added 2026-10-09 (objectui#11863 Q2, PR objectui#12044):** the last sentence above no longer holds. From objectui#11863 Q2 the landing mounts the command palette in its `studio` scope, so the header draws the search trigger and `⌘K` opens the palette; see `.changeset/11863-studio-search.md`.

**Clause-②: yes (widening; and one narrowing in `@object-ui/i18n`).** `AppHeader`'s `variant` prop accepts a fourth member, `'studio'`, beside `'app'`, `'home'` and `'orgs'`. It draws the brand and a fixed crumb, as `'orgs'` does. The union is not exported by name (the package entry exports `AppHeader` only), so a host meets it as the prop's type: a host that switches exhaustively over that type must handle `'studio'`. A call that passes no `variant` resolves it as before (`'app'` with an `appName`, `'home'` without). No export is added or removed.

`@object-ui/i18n`: each of the ten packs gains `console.studio.title` ("Studio", a product name every pack writes as is), the crumb's label, and drops `console.studio.backToHome`, the retired wordmark's tooltip, which nothing reads any more.
Expand Down
17 changes: 17 additions & 0 deletions .changeset/11863-studio-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': patch
'@object-ui/console': patch
---

The Studio landing (`/studio`) gets search (objectui#11863). It mounts the command palette, so its header shows the "Search ⌘K" trigger and `Ctrl+K` / `⌘K` opens the palette. Until now only the frame inside an app mounted one, so the landing's header drew no trigger (objectui#11912's rule). The entry for the landing's new header says it shows no search trigger; with this change it does.

On `/studio` no app is active, so the palette leaves out everything that belongs to an app: the app's objects, dashboards, pages and reports, record search, app switching, and the "Open Full Search Page" command, whose link starts with `/apps/APP` (mounted without an app it would have gone to `/apps/undefined/search`). It lists the Studio instead, in three groups:

- **Packages**: the packages the Studio landing lists (kernel packages left out). Each opens its Data pillar, `/studio/PKG/data`.
- **Objects**: the objects of those packages, each opened in its package's Data pillar on that object (`/studio/PKG/data?surface=object:NAME`).
- **Flows**: the flows of those packages, each opened in its package's Automations pillar on that flow (`/studio/PKG/automations?surface=flow:NAME`), and the flows that belong to no package, opened in the package-less scope (`/studio/~org/automations?surface=flow:NAME`).

An entry matches the query by its label or its machine name, as the palette's other entries do; an object or a packaged flow shows its package's name beside it. Nothing is read until the palette opens. Objects and flows are the published ones from the metadata cache, so an item that is still only a draft is not listed. The theme commands and the recently viewed records stay.

**Clause-②: yes (widening).** `CommandPalette`'s props gain a second form, `scope="studio"`, which takes no other prop. The props a host passes inside an app (`apps`, `activeApp`, `objects`, `onAppChange`, `dataSource`) are unchanged and still required there; that form leaves `scope` out. The props type is not exported by name (the package entry exports `CommandPalette` only), so a host meets it as the component's props. No export is added or removed. `@object-ui/i18n` gains two keys in all ten packs, `console.commandPalette.packages` and `console.commandPalette.flows`, the headings of two of the new groups; the objects group reuses `console.commandPalette.objects`.
177 changes: 177 additions & 0 deletions apps/console/src/components/StudioRoute.searchPalette-11863.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11863 (Q2) — the `/studio` landing mounts the command palette, in
* its `studio` scope, so the header's search trigger appears there and opens a
* palette that lists the Studio.
*
* Before it, only `ConsoleLayout` (inside an app) mounted a
* `CommandPaletteProvider`, and `AppHeader` draws its "Search ⌘K" trigger only
* under one (objectui#11912), so the landing's `studio` header had no search at
* all and `Ctrl+K` opened nothing. Pinned through the REAL `studioRoutes` tree,
* the real `AppHeader`, `CommandPaletteProvider` and `CommandPalette`, and the
* real `fetchPackages` over a stubbed `fetch`:
*
* - the header draws both triggers (desktop and compact) on `/studio`;
* - the desktop trigger and `Ctrl+K` each open the palette (`?palette=1`);
* - the palette lists the Studio's packages, and picking one opens its pillar
* builder through the real route tree.
*
* `t` answers in keys, as in `StudioRoute.landingI18n.test.tsx`, so a heading
* is read off the key it asks for.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';

/** Auth facts. This file only ever drives the holder path. */
const auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown };

function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response;
}

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => auth,
createAuthenticatedFetch: () => async () =>
jsonResponse({
authenticated: true,
userId: 'u1',
systemPermissions: ['studio.access'],
objects: {},
fields: {},
}),
}));

// `AuthGuard` reaches `useAuth` through the auth package's OWN module graph,
// not through its entry point — the same seam `StudioRoute.test.tsx` documents.
vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => auth,
}));

// The same barrel seam as `StudioRoute.landingI18n.test.tsx`: `chrome/` is
// pulled whole, so `CommandPalette` is the real one.
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
...(await vi.importActual<Record<string, unknown>>(
'../../../../packages/app-shell/src/chrome/index'
)),
ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
LoadingFallback: () => <div data-testid="loading" />,
LoadingScreen: () => <div data-testid="error-screen" />,
getProductName: () => 'ObjectOS',
BuilderLanding: () => <div data-testid="studio-front-door">pick a package</div>,
StudioDesignSurface: () => <div data-testid="studio-pillar-builder" />,
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string) => `«${key}»`,
}),
}));

import { studioRoutes } from './StudioRoute';

const DESKTOP = 'action:command-palette:open';
const MOBILE = 'action:command-palette:open-mobile';
const OVERLAY = 'overlay:command-palette';

function LocationProbe() {
const location = useLocation();
return <div data-testid="location" data-path={`${location.pathname}${location.search}`} />;
}

function renderStudio(at = '/studio') {
return render(
<MemoryRouter initialEntries={[at]}>
<Routes>
{studioRoutes}
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
<Route path="/login" element={<div data-testid="login-page">login</div>} />
</Routes>
<LocationProbe />
</MemoryRouter>,
);
}

function locationNow(): string {
return screen.getByTestId('location').getAttribute('data-path') ?? '';
}

beforeEach(() => {
vi.clearAllMocks();
// The package list answers two packages; every other read (the header's
// feeds, the AI agent catalogue) answers empty, so no request leaves the
// process.
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
String(url).endsWith('/api/v1/packages')
? jsonResponse({
data: [
{ manifest: { id: 'com.acme.crm', name: 'Acme CRM' }, writable: true },
{ manifest: { id: 'com.acme.hr', name: 'People' }, writable: true },
],
})
: jsonResponse({ data: [] }),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('the /studio landing mounts the command palette (objectui#11863, Q2)', () => {
it('draws the header search trigger, desktop and compact', async () => {
renderStudio();
await screen.findByTestId('studio-front-door');

expect(screen.getByTestId(DESKTOP)).toBeInTheDocument();
expect(screen.getByTestId(MOBILE)).toBeInTheDocument();
// Closed until asked.
expect(screen.queryByTestId(OVERLAY)).not.toBeInTheDocument();
});

it('opens the palette from the trigger, and it lists the Studio packages', async () => {
renderStudio();
await screen.findByTestId('studio-front-door');

fireEvent.click(screen.getByTestId(DESKTOP));
expect(await screen.findByTestId(OVERLAY)).toBeInTheDocument();
expect(locationNow()).toBe('/studio?palette=1');

expect(await screen.findByText('«console.commandPalette.packages»')).toBeInTheDocument();
expect(screen.getByText('Acme CRM')).toBeInTheDocument();
expect(screen.getByText('People')).toBeInTheDocument();
});

it('opens the palette on Ctrl+K', async () => {
renderStudio();
await screen.findByTestId('studio-front-door');

fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(await screen.findByTestId(OVERLAY)).toBeInTheDocument();
expect(locationNow()).toBe('/studio?palette=1');
});

it("picking a package opens that package's pillar builder", async () => {
renderStudio('/studio?palette=1');
fireEvent.click(await screen.findByText('People'));

expect(await screen.findByTestId('studio-pillar-builder')).toBeInTheDocument();
expect(locationNow()).toBe('/studio/com.acme.hr/data');
});
});
34 changes: 22 additions & 12 deletions apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import { lazy, Suspense, useEffect, type ReactNode } from 'react';
import { Navigate, Outlet, Route, useParams } from 'react-router-dom';
import {
AppHeader,
CommandPalette,
CommandPaletteProvider,
LoadingFallback,
LoadingScreen,
STUDIO_ORG_SCOPE_PILLAR,
Expand Down Expand Up @@ -151,22 +153,30 @@ export function StudioBuilderLoading() {
* and help, and the brand, then a fixed Studio crumb. The brand links to
* `useHomePath()`, so the landing is never a navigation dead end, and it names
* the same home as `StudioDesignSurface`'s Home button one route apart
* (objectui#7256, objectui#7373). No command palette is mounted here, so the
* header draws no search trigger (objectui#11912).
* (objectui#7256, objectui#7373).
*
* The frame mounts the command palette in its `studio` scope (objectui#11863):
* no app is active here, so it lists the Studio's packages, objects and flows
* instead of an app's entries. Under its `CommandPaletteProvider` the header
* draws the search trigger, as it does wherever a palette is mounted
* (objectui#11912), and `⌘K` opens it.
*/
function StudioLanding() {
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
<AppHeader variant="studio" />
</header>
<div className="min-h-0 flex-1 overflow-auto">
{/* The frame above stays painted while the landing's chunk loads. */}
<Suspense fallback={<StudioBuilderLoading />}>
<BuilderLanding />
</Suspense>
<CommandPaletteProvider>
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
<AppHeader variant="studio" />
</header>
<CommandPalette scope="studio" />
<div className="min-h-0 flex-1 overflow-auto">
{/* The frame above stays painted while the landing's chunk loads. */}
<Suspense fallback={<StudioBuilderLoading />}>
<BuilderLanding />
</Suspense>
</div>
</div>
</div>
</CommandPaletteProvider>
);
}

Expand Down
1 change: 1 addition & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
| **Expression Visibility** | Show/hide navigation items using `visible: "${data.role === 'admin'}"`. |
| **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. |
| **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. |
| **Search in Studio** | On the Studio landing (`/studio`) the header's search, or `⌘+K`, opens the command palette over the Studio rather than an app: its packages, their objects and their flows, matched by label or machine name. A package opens its Data pillar, an object opens in its package's Data pillar, a flow in its package's Automations pillar, and a flow that belongs to no package in `/studio/~org/automations`. App entries, record search and the full-search page are not offered there, since no app is open. |
| **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. |
| **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. |
| **Studio Automations Rail** | The Automations pillar's flow rail has a search box that matches a flow's label or its machine name, case-insensitively. A long flow name wraps instead of being cut off. |
Expand Down
10 changes: 10 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,16 @@ record search. Its open state and the command that opens it are provided by
`CommandPaletteProvider` (wired in by `ConsoleLayout`) and exposed via
`useCommandPalette()`.

`CommandPalette` has two scopes. Inside an app it takes `apps`, `activeApp`,
`objects`, `onAppChange` and an optional `dataSource`, and searches that app.
`<CommandPalette scope="studio" />` takes no other prop: it is the palette of the
`/studio` landing, a frame outside every app, which the console mounts under its
own `CommandPaletteProvider`. It leaves out every app-scoped group and the
full-search command (their links start with `/apps/APP`), and lists the Studio's
packages, their objects and their flows, each opening its Studio page. The
header's search trigger is drawn wherever a provider is mounted, so `AppHeader`
with `variant="studio"` shows it there.

```tsx
import { useCommandPalette } from '@object-ui/app-shell';

Expand Down
Loading
Loading