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
16 changes: 16 additions & 0 deletions .changeset/29458-native-connection-switcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/mobile': patch
'@object-ui/app-shell': patch
---

feat(mobile, app-shell): inside the ObjectStack mobile shell, the header's organization button opens the shell's native connection switcher, and the Console answers the shell's `connectionRemoved` event

Clause-②: yes (widening)
Metadata-protocol: none

The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both:

- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In the shell the button renders whenever there is an active organization, at one membership too, since the sheet lists the shell's connections across company servers; `CurrentOrganizationIndicator` then renders nothing, so the organization is drawn once. In a plain browser, or in a shell that does not announce the capability, both render as before.
- **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists.

`@object-ui/mobile` adds `showConnectionSwitcher` to `NativeHostMethod` and, as an optional method (an older shell does not carry it), to `ObjectStackNative`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@object-ui/fields": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/layout": "workspace:*",
"@object-ui/mobile": "workspace:*",
"@object-ui/permissions": "workspace:*",
"@object-ui/plugin-editor": "workspace:*",
"@object-ui/providers": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,29 @@
* `@objectstack/spec/security`, which measured +237 KB on the eager chunk. That
* file's docblock carries the measurement and the reasoning.
*
* Inside the mobile shell, when it announces `showConnectionSwitcher`, this
* renders nothing: `WorkspaceSwitcher` then draws the same badge and name, at
* the same widths, as the button that opens the shell's native switcher.
*
* An absent or unrecognized posture (older server, config fetch still in
* flight or failed) renders nothing, matching how every other posture-keyed
* affordance in this repo fails toward today's rendering.
*/

import { useAuth } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { nativeHostCan, useNativeHost } from '@object-ui/mobile';
import { OrgBadge } from './OrgBadge.js';
import { postureHasOrgWall, useTenancyPosture } from '../hooks/useTenancyPosture.js';

export function CurrentOrganizationIndicator() {
const { t } = useObjectTranslation();
const { organizations, activeOrganization } = useAuth();
const posture = useTenancyPosture();
const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher');

// The shell's switcher button carries the organization instead.
if (nativeSwitcher) return null;

// No organization wall in force ⇒ the top bar stays organization-silent,
// exactly as it is today. Also covers the not-yet-resolved config.
Expand Down
58 changes: 48 additions & 10 deletions ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@
* Header-left organization (workspace) switcher — the standard place users
* expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style).
*
* - Single-org users (the vast majority): render NOTHING. With one org there is
* nothing to switch to, so a dropdown here would control nothing. The
* switcher is a multi-org affordance. Where a tenancy wall IS in force the
* - Single-org users (the vast majority): render NOTHING on the web. With one
* org there is nothing to switch to, so a dropdown here would control
* nothing. The web switcher is a multi-org affordance (the mobile shell's
* native switcher, below, is not). Where a tenancy wall IS in force the
* org name still matters at one membership — `CurrentOrganizationIndicator`
* renders it read-only there (objectui#5287), without a control.
* - Multi-org users: the active org name + a dropdown to switch orgs inline
* (full-page reload so the active-org context refreshes app-wide, mirroring
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
* - No org context at all: renders nothing.
*
* Inside the ObjectStack mobile shell (`@object-ui/mobile`), when the shell
* announces `showConnectionSwitcher`, the button opens the shell's native
* switcher instead of the dropdown, and it renders whenever there is an active
* organization, at any membership count: the sheet lists the connections the
* shell holds across company servers, which this server's membership list
* cannot count. In a plain browser, or a shell without the capability, the
* rules above apply unchanged.
*
* Under `group` tenancy posture (ADR-0105) the switcher's meaning changes:
* the active organization is only the WRITE target — reads span every
* organization the member belongs to — so the dropdown labels it as the
Expand All @@ -25,6 +34,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '@object-ui/auth';
import type { AuthOrganization } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { nativeHostCan, nativeShowConnectionSwitcher, useNativeHost } from '@object-ui/mobile';
import {
DropdownMenu,
DropdownMenuTrigger,
Expand All @@ -45,6 +55,7 @@ export function WorkspaceSwitcher() {
const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth();
const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
const isGroupPosture = useTenancyPosture() === 'group';
const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher');

useEffect(() => {
let cancelled = false;
Expand All @@ -71,6 +82,38 @@ export function WorkspaceSwitcher() {
// rather than an empty switcher or a guessed name.
if (!current) return null;

const triggerClassName =
'ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground';
const triggerContent = (
<>
<OrgBadge name={current.name} />
<span className="hidden truncate sm:inline">{current.name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</>
);

// The shell's sheet replaces the web menu; switching there is the shell's
// own flow, so nothing is sent from here.
if (nativeSwitcher) {
return (
<button
type="button"
className={triggerClassName}
data-testid="workspace-switcher"
data-native-switcher=""
onClick={() => {
void nativeShowConnectionSwitcher().then((result) => {
if (result.status === 'failed') {
console.warn('[WorkspaceSwitcher] native connection switcher failed', result.code, result.message);
}
});
}}
>
{triggerContent}
</button>
);
}

// Single-org: render nothing. With only one org there's nothing to switch to,
// so a dropdown here would be a control that controls nothing. The switcher
// appears only once a second org exists. On a walled deployment the org NAME
Expand Down Expand Up @@ -102,13 +145,8 @@ export function WorkspaceSwitcher() {

return (
<DropdownMenu>
<DropdownMenuTrigger
className="ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
data-testid="workspace-switcher"
>
<OrgBadge name={current.name} />
<span className="hidden truncate sm:inline">{current.name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DropdownMenuTrigger className={triggerClassName} data-testid="workspace-switcher">
{triggerContent}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* WorkspaceSwitcher inside the ObjectStack mobile shell.
*
* When the shell's bridge (`window.ObjectStackNative`, read through
* `@object-ui/mobile`) announces `showConnectionSwitcher`, the header's
* organization button opens the shell's native switcher and the web menu does
* not open. With no bridge, or a bridge without that capability, the web menu
* opens exactly as before. The dropdown is the real one, opened by a real
* click, so "the web menu opens" is observed, not assumed.
*
* In the shell the button renders at ONE membership too: the sheet lists the
* shell's connections across servers, which this server's membership count
* cannot see. On a walled deployment `CurrentOrganizationIndicator` then steps
* aside, so the header carries the organization once, with the name shown at
* the same widths the indicator showed it.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
}),
}));

vi.mock('react-router-dom', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useNavigate: () => vi.fn(),
}));

let authState: Record<string, unknown>;
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => authState,
}));

import { NATIVE_HOST_GLOBAL } from '@object-ui/mobile';
import { WorkspaceSwitcher } from '../WorkspaceSwitcher';
import { CurrentOrganizationIndicator } from '../CurrentOrganizationIndicator';
import { useTenancyPosture } from '../../hooks/useTenancyPosture';

const ORGS = [
{ id: 'org_a', name: 'Plant A', slug: 'plant-a' },
{ id: 'org_b', name: 'Plant B', slug: 'plant-b' },
];

const BASE_METHODS = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer'];

function installBridge(capabilities: string[]) {
const showConnectionSwitcher = vi.fn(async () => null);
(window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL] = {
version: 1,
platform: 'ios',
appVersion: '2.1.0',
capabilities,
safeArea: { top: 47, bottom: 34, left: 0, right: 0 },
has: (capability: string) => capabilities.includes(capability),
getPushToken: vi.fn(async () => null),
haptic: vi.fn(async () => null),
openExternal: vi.fn(async () => null),
navigate: vi.fn(async () => null),
resetServer: vi.fn(async () => null),
showConnectionSwitcher,
ready: vi.fn(),
routeChanged: vi.fn(),
};
return showConnectionSwitcher;
}

beforeEach(() => {
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
authState = {
organizations: ORGS,
activeOrganization: ORGS[0],
switchOrganization: vi.fn(),
getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true } }),
};
});

afterEach(() => {
cleanup();
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
});

describe('WorkspaceSwitcher in the mobile shell', () => {
it('with showConnectionSwitcher announced, the button opens the native switcher and no web menu', async () => {
const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
render(<WorkspaceSwitcher />);

const button = screen.getByTestId('workspace-switcher');
expect(button).toHaveAttribute('data-native-switcher');
expect(button).not.toHaveAttribute('aria-haspopup');

await userEvent.click(button);

expect(showConnectionSwitcher).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(screen.queryByText('Plant B')).not.toBeInTheDocument();
expect(authState.switchOrganization).not.toHaveBeenCalled();
});

it('control: with no bridge, the button opens the web organization menu', async () => {
render(<WorkspaceSwitcher />);

const button = screen.getByTestId('workspace-switcher');
expect(button).not.toHaveAttribute('data-native-switcher');

await userEvent.click(button);

expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.getByText('Switch organization')).toBeInTheDocument();
expect(screen.getByText('Plant B')).toBeInTheDocument();
});

it('control: a shell without the capability keeps the web menu and never calls the native switcher', async () => {
const showConnectionSwitcher = installBridge(BASE_METHODS);
render(<WorkspaceSwitcher />);

const button = screen.getByTestId('workspace-switcher');
expect(button).not.toHaveAttribute('data-native-switcher');

await userEvent.click(button);

expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.getByText('Plant B')).toBeInTheDocument();
expect(showConnectionSwitcher).not.toHaveBeenCalled();
});
});

describe('WorkspaceSwitcher in the mobile shell at one membership (walled deployment)', () => {
/** AppHeader's pair, in its order, plus the resolved posture to wait on. */
function PostureProbe() {
return <span data-testid="posture">{String(useTenancyPosture() ?? 'unresolved')}</span>;
}
function mountHeaderPair() {
return render(
<>
<WorkspaceSwitcher />
<CurrentOrganizationIndicator />
<PostureProbe />
</>,
);
}

beforeEach(() => {
authState = {
organizations: [ORGS[0]],
activeOrganization: ORGS[0],
switchOrganization: vi.fn(),
getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true, tenancyPosture: 'isolated' } }),
};
});

/** The name span of whichever surface carries the organization. */
function nameSpan(container: HTMLElement): HTMLElement {
const spans = Array.from(container.querySelectorAll('span')).filter((el) => el.textContent === ORGS[0].name);
expect(spans).toHaveLength(1);
return spans[0] as HTMLElement;
}

it('with the capability, the native button renders, opens the sheet, and the read-only indicator steps aside', async () => {
const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
const { container } = mountHeaderPair();
await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated'));

const button = screen.getByTestId('workspace-switcher');
expect(button).toHaveAttribute('data-native-switcher');
expect(screen.queryByTestId('current-organization-indicator')).not.toBeInTheDocument();
// The organization is drawn once.
expect(nameSpan(container).closest('[data-testid="workspace-switcher"]')).toBe(button);

await userEvent.click(button);
expect(showConnectionSwitcher).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

it('the native button shows the name at the same widths the indicator did', async () => {
const plain = mountHeaderPair();
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
const indicatorNameClass = nameSpan(plain.container).className;
plain.unmount();

installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
const shell = mountHeaderPair();
await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated'));
expect(nameSpan(shell.container).className).toBe(indicatorNameClass);
});

it('control: with no bridge, one membership draws no switcher and the read-only indicator, as today', async () => {
mountHeaderPair();
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument();
});

it('control: a shell without the capability keeps today\'s single-membership rendering', async () => {
const showConnectionSwitcher = installBridge(BASE_METHODS);
mountHeaderPair();
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument();
expect(showConnectionSwitcher).not.toHaveBeenCalled();
});
});
Loading
Loading