Skip to content
64 changes: 64 additions & 0 deletions app/src/agentworld/AgentWorldShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { TinyPlaceIdentityState } from '../hooks/useTinyPlaceIdentity';
import AgentWorldShell from './AgentWorldShell';

let identity: TinyPlaceIdentityState = { status: 'ready', hasIdentity: true };
vi.mock('../hooks/useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identity }));
vi.mock('./TinyPlaceSunsetNotice', () => ({
default: () => <div data-testid="tinyplace-sunset-notice" />,
}));
vi.mock('../lib/agentworld/invokeApiClient', () => ({ createInvokeApiClient: () => ({}) }));

function renderShell() {
return render(
<MemoryRouter initialEntries={['/agent-world']}>
<Routes>
<Route
path="/agent-world"
element={
<AgentWorldShell>
<div data-testid="agent-world-content" />
</AgentWorldShell>
}
/>
<Route path="/chat" element={<div data-testid="chat-page" />} />
</Routes>
</MemoryRouter>
);
}

describe('AgentWorldShell tiny.place gate (#5424)', () => {
beforeEach(() => {
identity = { status: 'ready', hasIdentity: true };
});

it('renders the agent-world surface and the notice for an identity holder', () => {
identity = { status: 'ready', hasIdentity: true };
renderShell();

expect(screen.getByTestId('agent-world-content')).toBeInTheDocument();
expect(screen.getByTestId('tinyplace-sunset-notice')).toBeInTheDocument();
expect(screen.queryByTestId('chat-page')).toBeNull();
});

it('redirects a confirmed non-holder away to chat', () => {
identity = { status: 'ready', hasIdentity: false };
renderShell();

expect(screen.getByTestId('chat-page')).toBeInTheDocument();
expect(screen.queryByTestId('agent-world-content')).toBeNull();
});

it('renders optimistically while the identity check is still loading', () => {
identity = { status: 'loading', hasIdentity: false };
renderShell();

// A holder must not see a flash-then-redirect, so nothing redirects until
// the check confirms the user has no identity.
expect(screen.getByTestId('agent-world-content')).toBeInTheDocument();
expect(screen.queryByTestId('chat-page')).toBeNull();
});
});
20 changes: 19 additions & 1 deletion app/src/agentworld/AgentWorldShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
* not needed in the embedded context.
*/
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';

import { useTinyPlaceIdentity } from '../hooks/useTinyPlaceIdentity';
import { createInvokeApiClient } from '../lib/agentworld/invokeApiClient';
import TinyPlaceSunsetNotice from './TinyPlaceSunsetNotice';

interface AgentWorldShellProps {
children: ReactNode;
Expand All @@ -23,12 +26,27 @@ interface AgentWorldShellProps {
const apiClient = createInvokeApiClient();

export default function AgentWorldShell({ children }: AgentWorldShellProps) {
// #5424 — tiny.place is being removed from the app after 31 August 2026. A
// user without an identity has no entry point to it, so a direct link here is
// sent back to chat once we confirm they have none. Identity-holders keep full
// access (direct links included) plus the removal notice. While the check is
// in flight the surface renders optimistically so a holder never sees a flash.
const { status, hasIdentity } = useTinyPlaceIdentity();
if (status === 'ready' && !hasIdentity) {
return <Navigate to="/chat" replace />;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ major — a transient failure evicts an identity holder who is already on this surface, and the successful retry cannot bring them back.

The backoff added in d4eb8ece fixed the nav-tab half of the transient-failure problem, not the routing half. For a real holder whose wallet is locked at startup (the motivating case in the hook's own doc comment):

  1. They open on /agent-world (bookmark, deep link, restored window). status === 'loading' → renders optimistically. ✅
  2. The RPC rejects → publish({ status: 'ready', hasIdentity: false }).
  3. This line fires <Navigate to="/chat" replace /> — and because it is replace, the /agent-world history entry is gone.
  4. ~2s later the retry succeeds → hasIdentity: true. The nav tab reappears; their location does not.

So useTinyPlaceIdentity.ts:18-19 ("a one-time startup failure never locks a holder out until an app restart") holds for the entry point but not for where the user actually was. Brain.tsx:199 has the same behaviour for ?tab=orchestration.

Suggested change — give a transient error its own state, so gates that hide still hide but gates that evict stay permissive:

// useTinyPlaceIdentity.ts
- status: 'loading' | 'ready';
+ /** `error` = we could not ask (transient). Hide on it; never evict on it. */
+ status: 'loading' | 'ready' | 'error';
...
-    publish({ status: 'ready', hasIdentity: false });   // in catch
+    publish({ status: 'error', hasIdentity: false });

Neither this file nor Brain.tsx then needs a change — both already test status === 'ready', so 'error' falls through to rendering — and useNavTabs keeps hiding on hasIdentity === false. Worth adding a { status: 'error', hasIdentity: false } case to AgentWorldShell.test.tsx asserting no redirect.

}

// NOTE: When the vendored ApiProvider is available (from synced website/src),
// wrap children with <ApiProvider client={apiClient}>. For Wave 0 we expose
// the client via a context (see AgentWorldContext) so the Explore placeholder
// can demonstrate the end-to-end wiring without requiring the full vendor sync.
void apiClient; // referenced here to ensure the module is evaluated
return <>{children}</>;
return (
<>
<TinyPlaceSunsetNotice />
{children}
</>
);
}

export { apiClient };
31 changes: 31 additions & 0 deletions app/src/agentworld/TinyPlaceSunsetNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import TinyPlaceSunsetNotice from './TinyPlaceSunsetNotice';

const openUrl = vi.fn();
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../utils/openUrl', () => ({ openUrl: (url: string) => openUrl(url) }));

describe('TinyPlaceSunsetNotice (#5424)', () => {
it('renders the removal notice with a call to action', () => {
render(<TinyPlaceSunsetNotice />);

expect(screen.getByTestId('tinyplace-sunset-notice')).toBeInTheDocument();
expect(screen.getByText('tinyplaceSunset.title')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'tinyplaceSunset.cta' })).toBeInTheDocument();
});

it('opens tiny.place in the system browser when the CTA is clicked', () => {
render(<TinyPlaceSunsetNotice />);

fireEvent.click(screen.getByRole('button', { name: 'tinyplaceSunset.cta' }));
expect(openUrl).toHaveBeenCalledWith('https://tiny.place');
});

it('is not dismissible — no dismiss control is rendered', () => {
render(<TinyPlaceSunsetNotice />);

expect(screen.queryByRole('button', { name: 'common.dismiss' })).toBeNull();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — this assertion is vacuous.

UpsellBanner renders the close button only when both dismissible and onDismiss are truthy (components/upsell/UpsellBanner.tsx:80). TinyPlaceSunsetNotice never passes onDismiss, so this query returns null even if dismissible were flipped to true — the test cannot fail for the reason it claims to check.

Either assert the prop directly, or make the control case real:

// prove the query would find a dismiss button when one exists,
// so its absence in TinyPlaceSunsetNotice means something
it('is not dismissible — no dismiss control is rendered', () => {
  render(<TinyPlaceSunsetNotice />);
  expect(screen.queryByRole('button', { name: 'common.dismiss' })).toBeNull();
  // control: the same query does find one when the banner is dismissible
  render(<UpsellBanner variant="info" title="t" message="m" dismissible onDismiss={() => {}} />);
  expect(screen.getByRole('button', { name: 'common.dismiss' })).toBeInTheDocument();
});

});
});
33 changes: 33 additions & 0 deletions app/src/agentworld/TinyPlaceSunsetNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* tiny.place removal notice (#5424).
*
* Shown on the tiny.place surfaces (Agent World + the Brain orchestration
* sub-tab) to users who have an identity — the only people who still see the
* feature. It tells them to keep using tiny.place at tiny.place, names the
* 31 August 2026 in-app removal date, and links out. Non-dismissible: the
* deadline is fixed, so the notice stays until then.
*/
import UpsellBanner from '../components/upsell/UpsellBanner';
import { useT } from '../lib/i18n/I18nContext';
import { TINYPLACE_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';

export default function TinyPlaceSunsetNotice() {
const { t } = useT();

return (
<div className="relative z-20" data-testid="tinyplace-sunset-notice">
<UpsellBanner
variant="info"
title={t('tinyplaceSunset.title')}
message={t('tinyplaceSunset.message')}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 suggestionUpsellBanner truncates message to a single line, and the removal date is the last clause.

UpsellBanner renders the message as <p className="text-xs … truncate"> (components/upsell/UpsellBanner.tsx:68) — white-space: nowrap + ellipsis, inside a min-w-0 flex child. The English copy is ~90 chars and ends with after 31 August 2026, so the payload is the first thing clipped; de/ru/pl/bn are longer, and the Brain orchestration surface shares width with the sidebar. A sunset notice whose date can be ellipsed away isn't doing its job.

Suggested change — add an opt-out and use it here:

// UpsellBanner.tsx
- <p className={`text-xs ${styles.text} truncate`}>{message}</p>
+ <p className={`text-xs ${styles.text} ${wrapMessage ? '' : 'truncate'}`}>{message}</p>

// TinyPlaceSunsetNotice.tsx
  <UpsellBanner variant="info"  rounded={false} dismissible={false}
+   wrapMessage

Also worth role="status" on the wrapper div (line 19) so a non-dismissible notice is announced, and aria-hidden="true" on the banner's decorative icon.

ctaLabel={t('tinyplaceSunset.cta')}
rounded={false}
dismissible={false}
onCtaClick={() => {
void openUrl(TINYPLACE_URL);
}}
/>
</div>
);
}
6 changes: 6 additions & 0 deletions app/src/components/layout/shell/CollapsedNavRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ vi.mock('./useHomeNav', () => ({ useHomeNav: () => mockHome }));
// Deterministic labels: render the i18n key so queries don't depend on locale.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
// The agent-world tab is gated on a tiny.place identity (#5424). These tests
// exercise the full rail, so pin identity present; the gate is covered by
// useNavTabs.test.ts.
vi.mock('../../../hooks/useTinyPlaceIdentity', () => ({
useTinyPlaceIdentity: () => ({ status: 'ready', hasIdentity: true }),
}));

describe('CollapsedNavRail', () => {
beforeEach(() => vi.clearAllMocks());
Expand Down
9 changes: 7 additions & 2 deletions app/src/components/layout/shell/CollapsedNavRail.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { NAV_TABS, type NavTab } from '../../../config/navConfig';
import { type NavTab } from '../../../config/navConfig';
import { useNavTabs } from '../../../hooks/useNavTabs';
import { registry } from '../../../lib/commands/registry';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
Expand Down Expand Up @@ -36,7 +37,11 @@ export default function CollapsedNavRail() {
const handleHome = useHomeNav();
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));

const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]);
const navTabs = useNavTabs();
const tabs = useMemo(
() => navTabs.map(tab => ({ ...tab, label: t(tab.labelKey) })),
[navTabs, t]
);
const activeTab = tabs.find(tab => matchActive(tab.path, location.pathname));

const handleClick = (tab: NavTab, active: boolean) => {
Expand Down
7 changes: 7 additions & 0 deletions app/src/components/layout/shell/SidebarNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import SidebarNav from './SidebarNav';
// Analytics is fire-and-forget; stub it so the nav renders without a transport.
vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));

// The Tiny.Place (agent-world) tab is gated on a tiny.place identity (#5424).
// These tests exercise active-route matching with the full nav, so pin identity
// present; the gate itself is covered by useNavTabs.test.ts.
vi.mock('../../../hooks/useTinyPlaceIdentity', () => ({
useTinyPlaceIdentity: () => ({ status: 'ready', hasIdentity: true }),
}));

/** The rendered button for a nav label (label text lives in a child span). */
function tabButton(label: string): HTMLButtonElement {
return screen.getByRole('button', { name: new RegExp(label) }) as HTMLButtonElement;
Expand Down
9 changes: 7 additions & 2 deletions app/src/components/layout/shell/SidebarNav.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { NAV_TABS, type NavTab } from '../../../config/navConfig';
import { type NavTab } from '../../../config/navConfig';
import { useNavTabs } from '../../../hooks/useNavTabs';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { setActiveAccount } from '../../../store/accountsSlice';
Expand Down Expand Up @@ -46,7 +47,11 @@ export default function SidebarNav() {
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
const companionActive = useAppSelector(selectCompanionSessionActive);

const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]);
const navTabs = useNavTabs();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 suggestion — the onboarding walkthrough still points at this now-conditional tab.

components/walkthrough/walkthroughSteps.ts:162-168 (step 10) targets [data-walkthrough="tab-agent-world"]. The tour runs immediately after onboarding (setWalkthroughPending() on wizard completion), and a freshly-onboarded user is by definition not an identity holder — so that node is absent for essentially every run of the tour.

It doesn't hang: react-joyride logs Target not mounted, records a target_not_found failure, emits EVENTS.TARGET_NOT_FOUND and auto-advances (react-joyride/dist/index.mjs:1249-1259), and ACTIONS.PREV decrements correctly too. But the step — plus its walkthrough.steps.agentWorldTab.* copy in 14 locales — is now dead for most users, and it teaches a feature that is being removed.

Suggested change — make the step list identity-aware the same way the nav now is:

// AppWalkthrough.tsx
- const steps = useMemo(() => createWalkthroughSteps(navigate, t), [navigate, t]);
+ const { hasIdentity } = useTinyPlaceIdentity();
+ const steps = useMemo(
+   () => createWalkthroughSteps(navigate, t).filter(
+     s => hasIdentity || s.target !== '[data-walkthrough="tab-agent-world"]'
+   ),
+   [navigate, t, hasIdentity]
+ );

const tabs = useMemo(
() => navTabs.map(tab => ({ ...tab, label: t(tab.labelKey) })),
[navTabs, t]
);
const activeTab = tabs.find(tab => matchActive(tab.path, location.pathname));

const handleClick = (tab: NavTab, active: boolean) => {
Expand Down
41 changes: 41 additions & 0 deletions app/src/hooks/useNavTabs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useNavTabs } from './useNavTabs';
import type { TinyPlaceIdentityState } from './useTinyPlaceIdentity';

// Hoisted so the vi.mock factory can legally reference it (the repo convention
// for controllable mock state — see Brain.test.tsx).
const identityRef = vi.hoisted(() => ({
current: { status: 'ready', hasIdentity: false } as TinyPlaceIdentityState,
}));
vi.mock('./useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identityRef.current }));

describe('useNavTabs (#5424)', () => {
beforeEach(() => {
identityRef.current = { status: 'ready', hasIdentity: false };
});

it('hides the agent-world (tiny.place) tab when the user has no identity', () => {
identityRef.current = { status: 'ready', hasIdentity: false };
const { result } = renderHook(() => useNavTabs());

expect(result.current.some(tab => tab.id === 'agent-world')).toBe(false);
// The other primary tabs are untouched.
expect(result.current.some(tab => tab.id === 'chat')).toBe(true);
});

it('shows the agent-world tab for a user with a tiny.place identity', () => {
identityRef.current = { status: 'ready', hasIdentity: true };
const { result } = renderHook(() => useNavTabs());

expect(result.current.some(tab => tab.id === 'agent-world')).toBe(true);
});

it('keeps the tab hidden while the identity check is still loading', () => {
identityRef.current = { status: 'loading', hasIdentity: false };
const { result } = renderHook(() => useNavTabs());

expect(result.current.some(tab => tab.id === 'agent-world')).toBe(false);
});
});
21 changes: 21 additions & 0 deletions app/src/hooks/useNavTabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* The visible primary nav tabs (#5424).
*
* Identical to {@link NAV_TABS} except the `agent-world` (tiny.place) tab is
* hidden from users without a tiny.place identity — the feature is being removed
* after 31 August 2026 and its entry points must only appear for people who
* already have one. Both nav renderers (expanded {@link SidebarNav} and the
* collapsed rail) consume this so the rule lives in one place.
*/
import { useMemo } from 'react';

import { NAV_TABS, type NavTab } from '../config/navConfig';
import { useTinyPlaceIdentity } from './useTinyPlaceIdentity';

export function useNavTabs(): NavTab[] {
const { hasIdentity } = useTinyPlaceIdentity();
return useMemo(
() => NAV_TABS.filter(tab => tab.id !== 'agent-world' || hasIdentity),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ major — missed entry point: the tiny.place autopilot toggle in Settings is still shown to non-holders.

Six doors are gated by this PR (nav ×2, /agent-world/*, the Brain chip, ?tab=orchestration, and the legacy /orchestration + settings-tasks redirects that funnel into it). This is the seventh:

app/src/components/settings/panels/AgentAccessPanel.tsx:394-413 renders an "Autonomous tiny.place agent" section whose only condition is autopilotJobId — i.e. the seeded cron job exists. That job is backfilled at boot for every user (src/openhuman/cron/seed.rs:119-123"boot seed — backfilling tinyplace_autopilot (disabled, opt-in)"), so the condition is identity-independent.

Net effect after this PR: a user with no tiny.place identity has the tab hidden, the route bounced and the Brain chip removed — and can still switch on an autonomous tiny.place agent from Settings, for a surface they cannot open and that is being removed. (The panel isn't in this diff, which is presumably how it slipped.)

Suggested change:

// AgentAccessPanel.tsx
+ import { useTinyPlaceIdentity } from '../../../hooks/useTinyPlaceIdentity';
+ const { hasIdentity: hasTinyplaceIdentity } = useTinyPlaceIdentity();

- {autopilotJobId && (
+ {autopilotJobId && hasTinyplaceIdentity && (
    <SettingsSection title={t('settings.agentAccess.tinyplaceAutopilot.title')} >

Plus a two-branch test in the panel spec, mirroring useNavTabs.test.ts.

[hasIdentity]
);
}
Loading
Loading