diff --git a/app/src/agentworld/AgentWorldShell.test.tsx b/app/src/agentworld/AgentWorldShell.test.tsx new file mode 100644 index 0000000000..8349f09ffc --- /dev/null +++ b/app/src/agentworld/AgentWorldShell.test.tsx @@ -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: () =>
, +})); +vi.mock('../lib/agentworld/invokeApiClient', () => ({ createInvokeApiClient: () => ({}) })); + +function renderShell() { + return render( + + + +
+ + } + /> + } /> + + + ); +} + +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(); + }); +}); diff --git a/app/src/agentworld/AgentWorldShell.tsx b/app/src/agentworld/AgentWorldShell.tsx index 10e9189dc5..704b6bb11f 100644 --- a/app/src/agentworld/AgentWorldShell.tsx +++ b/app/src/agentworld/AgentWorldShell.tsx @@ -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; @@ -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 ; + } + // NOTE: When the vendored ApiProvider is available (from synced website/src), // wrap children with . 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 ( + <> + + {children} + + ); } export { apiClient }; diff --git a/app/src/agentworld/TinyPlaceSunsetNotice.test.tsx b/app/src/agentworld/TinyPlaceSunsetNotice.test.tsx new file mode 100644 index 0000000000..7fca66a9b5 --- /dev/null +++ b/app/src/agentworld/TinyPlaceSunsetNotice.test.tsx @@ -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(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: 'tinyplaceSunset.cta' })); + expect(openUrl).toHaveBeenCalledWith('https://tiny.place'); + }); + + it('is not dismissible — no dismiss control is rendered', () => { + render(); + + expect(screen.queryByRole('button', { name: 'common.dismiss' })).toBeNull(); + }); +}); diff --git a/app/src/agentworld/TinyPlaceSunsetNotice.tsx b/app/src/agentworld/TinyPlaceSunsetNotice.tsx new file mode 100644 index 0000000000..348dc9a1ca --- /dev/null +++ b/app/src/agentworld/TinyPlaceSunsetNotice.tsx @@ -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 ( +
+ { + void openUrl(TINYPLACE_URL); + }} + /> +
+ ); +} diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index b9d10b6120..684a301e99 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -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()); diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index 2f045f8ee3..89b373d162 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -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'; @@ -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) => { diff --git a/app/src/components/layout/shell/SidebarNav.test.tsx b/app/src/components/layout/shell/SidebarNav.test.tsx index 790e8289a3..f3d40353a5 100644 --- a/app/src/components/layout/shell/SidebarNav.test.tsx +++ b/app/src/components/layout/shell/SidebarNav.test.tsx @@ -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; diff --git a/app/src/components/layout/shell/SidebarNav.tsx b/app/src/components/layout/shell/SidebarNav.tsx index 89d8ec26bd..5958a4cdf9 100644 --- a/app/src/components/layout/shell/SidebarNav.tsx +++ b/app/src/components/layout/shell/SidebarNav.tsx @@ -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'; @@ -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(); + 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) => { diff --git a/app/src/hooks/useNavTabs.test.ts b/app/src/hooks/useNavTabs.test.ts new file mode 100644 index 0000000000..3fcaf8129b --- /dev/null +++ b/app/src/hooks/useNavTabs.test.ts @@ -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); + }); +}); diff --git a/app/src/hooks/useNavTabs.ts b/app/src/hooks/useNavTabs.ts new file mode 100644 index 0000000000..48a91e0037 --- /dev/null +++ b/app/src/hooks/useNavTabs.ts @@ -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), + [hasIdentity] + ); +} diff --git a/app/src/hooks/useTinyPlaceIdentity.test.ts b/app/src/hooks/useTinyPlaceIdentity.test.ts new file mode 100644 index 0000000000..2ed4ec5490 --- /dev/null +++ b/app/src/hooks/useTinyPlaceIdentity.test.ts @@ -0,0 +1,92 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __resetTinyPlaceIdentityForTests, useTinyPlaceIdentity } from './useTinyPlaceIdentity'; + +const selfIdentity = vi.fn(); +vi.mock('../lib/orchestration/orchestrationClient', () => ({ + orchestrationClient: { selfIdentity: () => selfIdentity() }, +})); + +describe('useTinyPlaceIdentity (#5424)', () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetTinyPlaceIdentityForTests(); + }); + + afterEach(() => { + __resetTinyPlaceIdentityForTests(); + vi.useRealTimers(); + }); + + it('reports an identity when the RPC returns a non-empty agentId', async () => { + selfIdentity.mockResolvedValue({ agentId: 'agent-123', handles: [], discoverable: true }); + const { result } = renderHook(() => useTinyPlaceIdentity()); + + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.hasIdentity).toBe(true); + }); + + it('reports no identity when the agentId is blank', async () => { + selfIdentity.mockResolvedValue({ agentId: ' ', handles: [], discoverable: false }); + const { result } = renderHook(() => useTinyPlaceIdentity()); + + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.hasIdentity).toBe(false); + }); + + it('stays fail-closed (hidden) while a rejected check is retried', async () => { + selfIdentity.mockRejectedValue(new Error('wallet locked')); + const { result } = renderHook(() => useTinyPlaceIdentity()); + + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.hasIdentity).toBe(false); + }); + + it('fetches once and shares the result across consumers', async () => { + selfIdentity.mockResolvedValue({ agentId: 'agent-123', handles: [], discoverable: true }); + const first = renderHook(() => useTinyPlaceIdentity()); + const second = renderHook(() => useTinyPlaceIdentity()); + + await waitFor(() => expect(first.result.current.status).toBe('ready')); + expect(second.result.current.hasIdentity).toBe(true); + // A single resolved RPC backs every consumer for the app session. + expect(selfIdentity).toHaveBeenCalledTimes(1); + }); + + it('retries after a transient failure and recovers without a restart (#5439 review)', async () => { + vi.useFakeTimers(); + selfIdentity + .mockRejectedValueOnce(new Error('wallet locked at startup')) + .mockResolvedValue({ agentId: 'agent-123', handles: [], discoverable: true }); + const { result } = renderHook(() => useTinyPlaceIdentity()); + + // First attempt fails → fail-closed (hidden), but a backoff retry is queued. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ status: 'ready', hasIdentity: false }); + + // The retry fires and succeeds → a real holder is no longer locked out. + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(result.current.hasIdentity).toBe(true); + }); + + it('re-checks immediately on window focus after a failed startup check (#5439 review)', async () => { + selfIdentity + .mockRejectedValueOnce(new Error('wallet locked')) + .mockResolvedValue({ agentId: 'agent-123', handles: [], discoverable: true }); + const { result } = renderHook(() => useTinyPlaceIdentity()); + + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.hasIdentity).toBe(false); + + // The user unlocks their wallet and returns to the app. + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + await waitFor(() => expect(result.current.hasIdentity).toBe(true)); + }); +}); diff --git a/app/src/hooks/useTinyPlaceIdentity.ts b/app/src/hooks/useTinyPlaceIdentity.ts new file mode 100644 index 0000000000..7205f7b187 --- /dev/null +++ b/app/src/hooks/useTinyPlaceIdentity.ts @@ -0,0 +1,123 @@ +/** + * Whether this user has set up a tiny.place identity (#5424). + * + * tiny.place is being removed from the app after 31 August 2026. Its entry + * points must be shown only to users who already have an identity — everyone + * else never really started, so there is nothing to preserve and no reason to + * advertise a feature that is about to disappear. + * + * The authoritative signal is the orchestration self-identity RPC: a non-empty + * `agentId` means the wallet-backed identity exists. + * + * Failure handling is deliberate. A *resolved* call (identity present or absent) + * is terminal and cached for the session. A *rejected* call is treated as + * transient — the wallet/keyring can be locked during startup, or the relay can + * blip — so it must NOT permanently hide tiny.place from a real holder. On + * rejection we stay fail-closed (hidden) for the moment but keep retrying with a + * bounded backoff, and re-attempt when the window regains focus (e.g. after the + * user unlocks their wallet), so a one-time startup failure never locks a holder + * out until an app restart. + * + * The check is shared module-side, so the several gates that read it (nav tabs, + * the agent-world route, the Brain orchestration sub-tab, the notice) share a + * single in-flight request rather than each firing their own. + */ +import { useEffect, useState } from 'react'; + +import { orchestrationClient } from '../lib/orchestration/orchestrationClient'; + +export interface TinyPlaceIdentityState { + /** `loading` until the RPC first settles; `ready` once we have an answer. */ + status: 'loading' | 'ready'; + /** True only when a tiny.place identity exists for this user. */ + hasIdentity: boolean; +} + +/** Bounded backoff (ms) between retries after a transient failure. */ +const RETRY_DELAYS_MS = [2000, 5000, 10000]; + +let cache: TinyPlaceIdentityState = { status: 'loading', hasIdentity: false }; +let resolved = false; // the RPC returned a definitive answer — stop retrying +let inFlight = false; +let retryTimer: ReturnType | null = null; +const listeners = new Set<() => void>(); + +function publish(next: TinyPlaceIdentityState) { + cache = next; + listeners.forEach(listener => listener()); +} + +async function attemptLoad(attempt: number) { + if (resolved || inFlight) return; + inFlight = true; + try { + const identity = await orchestrationClient.selfIdentity(); + resolved = true; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + publish({ status: 'ready', hasIdentity: identity.agentId.trim().length > 0 }); + } catch { + // Transient: stay fail-closed (hidden) but schedule a bounded retry so a + // real holder is not locked out for the whole session by a startup blip. + publish({ status: 'ready', hasIdentity: false }); + const delay = RETRY_DELAYS_MS[attempt]; + if (delay !== undefined && !retryTimer) { + retryTimer = setTimeout(() => { + retryTimer = null; + void attemptLoad(attempt + 1); + }, delay); + } + } finally { + inFlight = false; + } +} + +/** Kick a load if one isn't already settled, in flight, or scheduled. */ +function ensureLoad() { + if (!resolved && !inFlight && !retryTimer) void attemptLoad(0); +} + +/** Test seam — clears the module cache so each test starts from `loading`. */ +export function __resetTinyPlaceIdentityForTests() { + cache = { status: 'loading', hasIdentity: false }; + resolved = false; + inFlight = false; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + listeners.clear(); +} + +export function useTinyPlaceIdentity(): TinyPlaceIdentityState { + const [state, setState] = useState(cache); + + useEffect(() => { + const sync = () => setState(cache); + listeners.add(sync); + ensureLoad(); + // If the first check failed and the user later unlocks their wallet, a + // window refocus re-attempts immediately (cancelling any pending backoff) so + // a real holder isn't hidden until restart. + const onFocus = () => { + if (resolved || inFlight) return; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + void attemptLoad(0); + }; + window.addEventListener('focus', onFocus); + // Adopt the current cache in case it resolved between the initial render and + // this effect firing (or a sibling hook already loaded it). + sync(); + return () => { + listeners.delete(sync); + window.removeEventListener('focus', onFocus); + }; + }, []); + + return state; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2d5c15abb8..728491b8c8 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7034,6 +7034,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'انتهت ميزانية التضمينات لديك، لذلك لم يعد المحتوى الجديد يُضاف إلى الذاكرة. أعدّ تضمينات محلية أو أضف مفتاح API الخاص بك للمتابعة.', 'memoryBudget.cta': 'إعداد التضمينات', + 'tinyplaceSunset.title': 'ينتقل Tiny Place خارج التطبيق', + 'tinyplaceSunset.message': + 'لمواصلة استخدام tiny.place، زر tiny.place. ستتم إزالته من التطبيق بعد 31 أغسطس 2026.', + 'tinyplaceSunset.cta': 'فتح tiny.place', 'userErrors.scope.memory': 'الذاكرة', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'المبلغ', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3389811573..4c0522089b 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7190,6 +7190,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'আপনার এমবেডিং বাজেট শেষ, তাই নতুন কনটেন্ট আর মেমরিতে যুক্ত হচ্ছে না। আবার শুরু করতে লোকাল এমবেডিং সেট আপ করুন বা নিজের API কী যোগ করুন।', 'memoryBudget.cta': 'এমবেডিং সেট আপ করুন', + 'tinyplaceSunset.title': 'Tiny Place অ্যাপ থেকে সরে যাচ্ছে', + 'tinyplaceSunset.message': + 'tiny.place ব্যবহার চালিয়ে যেতে tiny.place-এ যান। ৩১ আগস্ট ২০২৬-এর পর এটি অ্যাপ থেকে সরিয়ে ফেলা হবে।', + 'tinyplaceSunset.cta': 'tiny.place খুলুন', 'userErrors.scope.memory': 'মেমরি', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'পরিমাণ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ad983a8348..ab606eb7b3 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7394,6 +7394,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Dein Embedding-Budget ist aufgebraucht, daher werden keine neuen Inhalte mehr ins Gedächtnis aufgenommen. Richte lokale Embeddings ein oder hinterlege deinen eigenen API-Schlüssel, um fortzufahren.', 'memoryBudget.cta': 'Embeddings einrichten', + 'tinyplaceSunset.title': 'Tiny Place zieht aus der App aus', + 'tinyplaceSunset.message': + 'Um tiny.place weiter zu nutzen, besuche tiny.place. Nach dem 31. August 2026 wird es aus der App entfernt.', + 'tinyplaceSunset.cta': 'tiny.place öffnen', 'userErrors.scope.memory': 'Speicher', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Betrag', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 5e3bdb0768..6a7986e99c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7593,6 +7593,10 @@ const en: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Your embedding budget is used up, so new content is no longer being added to memory. Set up local embeddings or add your own API key to resume.', 'memoryBudget.cta': 'Set up embeddings', + 'tinyplaceSunset.title': 'Tiny Place is moving out of the app', + 'tinyplaceSunset.message': + 'To keep using tiny.place, visit tiny.place. It will be removed from the app after 31 August 2026.', + 'tinyplaceSunset.cta': 'Open tiny.place', 'memorySources.codingSessions.title': 'Coding-agent sessions', 'memorySources.codingSessions.description': 'Turn your Codex and Claude Code decisions and corrections into private persona memory.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 70bb1b8817..7c7d5df858 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7341,6 +7341,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Tu presupuesto de embeddings se agotó, así que el contenido nuevo ya no se añade a la memoria. Configura embeddings locales o añade tu propia clave de API para reanudar.', 'memoryBudget.cta': 'Configurar embeddings', + 'tinyplaceSunset.title': 'Tiny Place se va de la app', + 'tinyplaceSunset.message': + 'Para seguir usando tiny.place, entra en tiny.place. Se eliminará de la app después del 31 de agosto de 2026.', + 'tinyplaceSunset.cta': 'Abrir tiny.place', 'userErrors.scope.memory': 'Memoria', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Importe', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8ecf52894f..2be7397644 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7372,6 +7372,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': "Votre budget d'embeddings est épuisé, les nouveaux contenus ne sont donc plus ajoutés à la mémoire. Configurez des embeddings locaux ou ajoutez votre propre clé API pour reprendre.", 'memoryBudget.cta': 'Configurer les embeddings', + 'tinyplaceSunset.title': 'Tiny Place quitte l’application', + 'tinyplaceSunset.message': + 'Pour continuer à utiliser tiny.place, rendez-vous sur tiny.place. Il sera retiré de l’application après le 31 août 2026.', + 'tinyplaceSunset.cta': 'Ouvrir tiny.place', 'userErrors.scope.memory': 'Mémoire', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Montant', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index fabe10252e..0254068738 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7189,6 +7189,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'आपका एम्बेडिंग बजट खत्म हो गया है, इसलिए नई सामग्री अब मेमोरी में नहीं जुड़ रही। दोबारा शुरू करने के लिए लोकल एम्बेडिंग सेट करें या अपनी API कुंजी जोड़ें।', 'memoryBudget.cta': 'एम्बेडिंग सेट करें', + 'tinyplaceSunset.title': 'Tiny Place ऐप से बाहर जा रहा है', + 'tinyplaceSunset.message': + 'tiny.place का उपयोग जारी रखने के लिए tiny.place पर जाएं। 31 अगस्त 2026 के बाद इसे ऐप से हटा दिया जाएगा।', + 'tinyplaceSunset.cta': 'tiny.place खोलें', 'userErrors.scope.memory': 'मेमोरी', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'राशि', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 254003fad6..a9f93f83bf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7229,6 +7229,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Anggaran embedding Anda sudah habis, sehingga konten baru tidak lagi ditambahkan ke memori. Siapkan embedding lokal atau tambahkan kunci API Anda sendiri untuk melanjutkan.', 'memoryBudget.cta': 'Siapkan embedding', + 'tinyplaceSunset.title': 'Tiny Place keluar dari aplikasi', + 'tinyplaceSunset.message': + 'Untuk terus memakai tiny.place, kunjungi tiny.place. Fitur ini akan dihapus dari aplikasi setelah 31 Agustus 2026.', + 'tinyplaceSunset.cta': 'Buka tiny.place', 'userErrors.scope.memory': 'Memori', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Jumlah', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 8ac5dfe09f..5e9a6aa550 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7324,6 +7324,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Il tuo budget di embedding è esaurito, quindi i nuovi contenuti non vengono più aggiunti alla memoria. Configura embedding locali o aggiungi la tua chiave API per riprendere.', 'memoryBudget.cta': 'Configura gli embedding', + 'tinyplaceSunset.title': 'Tiny Place esce dall’app', + 'tinyplaceSunset.message': + 'Per continuare a usare tiny.place, vai su tiny.place. Verrà rimosso dall’app dopo il 31 agosto 2026.', + 'tinyplaceSunset.cta': 'Apri tiny.place', 'userErrors.scope.memory': 'Memoria', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Importo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0ad98cd9a5..20bc1e1919 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7110,6 +7110,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': '임베딩 예산을 모두 사용해 새 콘텐츠가 메모리에 추가되지 않습니다. 로컬 임베딩을 설정하거나 본인의 API 키를 추가하면 다시 시작됩니다.', 'memoryBudget.cta': '임베딩 설정', + 'tinyplaceSunset.title': 'Tiny Place가 앱에서 분리됩니다', + 'tinyplaceSunset.message': + 'tiny.place를 계속 사용하려면 tiny.place에서 이용하세요. 2026년 8월 31일 이후 앱에서 제거됩니다.', + 'tinyplaceSunset.cta': 'tiny.place 열기', 'userErrors.scope.memory': '메모리', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': '금액', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index a4a7e33fbf..9c92192a8f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7295,6 +7295,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Twój budżet osadzeń został wyczerpany, więc nowe treści nie są już dodawane do pamięci. Skonfiguruj lokalne osadzenia lub dodaj własny klucz API, aby wznowić.', 'memoryBudget.cta': 'Skonfiguruj osadzenia', + 'tinyplaceSunset.title': 'Tiny Place znika z aplikacji', + 'tinyplaceSunset.message': + 'Aby dalej korzystać z tiny.place, wejdź na tiny.place. Po 31 sierpnia 2026 r. zostanie usunięty z aplikacji.', + 'tinyplaceSunset.cta': 'Otwórz tiny.place', 'userErrors.scope.memory': 'Pamięć', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Kwota', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d063a45436..8fb2c40bea 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7308,6 +7308,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Seu orçamento de embeddings acabou, então novos conteúdos não estão mais sendo adicionados à memória. Configure embeddings locais ou adicione sua própria chave de API para retomar.', 'memoryBudget.cta': 'Configurar embeddings', + 'tinyplaceSunset.title': 'Tiny Place está saindo do app', + 'tinyplaceSunset.message': + 'Para continuar usando o tiny.place, acesse tiny.place. Ele será removido do app depois de 31 de agosto de 2026.', + 'tinyplaceSunset.cta': 'Abrir tiny.place', 'userErrors.scope.memory': 'Memória', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Valor', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a9bfc81894..521a9aef19 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7269,6 +7269,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Бюджет эмбеддингов израсходован, поэтому новые данные больше не добавляются в память. Настройте локальные эмбеддинги или добавьте свой ключ API, чтобы продолжить.', 'memoryBudget.cta': 'Настроить эмбеддинги', + 'tinyplaceSunset.title': 'Tiny Place уходит из приложения', + 'tinyplaceSunset.message': + 'Чтобы продолжить пользоваться tiny.place, откройте tiny.place. После 31 августа 2026 года он будет удалён из приложения.', + 'tinyplaceSunset.cta': 'Открыть tiny.place', 'userErrors.scope.memory': 'Память', // Agent World: Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Сумма', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index cc4dfa0f4e..e82ac2b93f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6806,6 +6806,10 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': '你的嵌入额度已用尽,新内容不会再加入记忆。设置本地嵌入或添加你自己的 API 密钥即可恢复。', 'memoryBudget.cta': '设置嵌入', + 'tinyplaceSunset.title': 'Tiny Place 即将移出应用', + 'tinyplaceSunset.message': + '若要继续使用 tiny.place,请前往 tiny.place。2026 年 8 月 31 日后它将从应用中移除。', + 'tinyplaceSunset.cta': '打开 tiny.place', 'userErrors.scope.memory': '记忆', // Agent World:Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': '金额', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index b51d4e43c2..82214a4546 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -6,8 +6,9 @@ * former top-level `/orchestration` tab — see {@link OrchestrationView}). */ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import TinyPlaceSunsetNotice from '../agentworld/TinyPlaceSunsetNotice'; import { CodingSessionsCard } from '../components/intelligence/CodingSessionsCard'; import GoalsPanel from '../components/intelligence/GoalsPanel'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; @@ -26,6 +27,7 @@ import TwoPaneNav from '../components/layout/TwoPaneNav'; import OrchestrationView from '../components/orchestration/OrchestrationView'; import BetaBanner from '../components/ui/BetaBanner'; import { useSubconscious } from '../hooks/useSubconscious'; +import { useTinyPlaceIdentity } from '../hooks/useTinyPlaceIdentity'; import { useT } from '../lib/i18n/I18nContext'; import { useCoreState } from '../providers/CoreStateProvider'; import type { ToastNotification } from '../types/intelligence'; @@ -105,6 +107,18 @@ export default function Brain() { } }, [location.search, navigate]); + // #5424 — the Orchestration sub-tab is a tiny.place surface, hidden from users + // without an identity. If a *confirmed* non-holder lands on `?tab=orchestration` + // via a stale deep link, redirect to the Brain welcome tab. This is a + // render-phase redirect (not a post-commit effect), so once the check resolves + // to a non-holder OrchestrationView never mounts. While the check is still in + // flight we render optimistically — matching the AgentWorldShell route guard — + // so a holder (the common case) never sees a flash; the brief optimistic window + // for a stale non-holder link is the deliberate trade-off. + const { status: tinyplaceStatus, hasIdentity: hasTinyplaceIdentity } = useTinyPlaceIdentity(); + const shouldRedirectFromOrchestration = + activeTab === 'orchestration' && tinyplaceStatus === 'ready' && !hasTinyplaceIdentity; + const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState('tree'); @@ -182,6 +196,10 @@ export default function Brain() { const cardClass = 'rounded-lg border border-line bg-surface p-4'; + if (shouldRedirectFromOrchestration) { + return ; + } + return (
{/* The Brain navigation lives in the root app sidebar's dynamic region. */} @@ -232,15 +250,20 @@ export default function Brain() { 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' ), }, - { - // TinyPlace multi-agent orchestration, folded back under - // Brain from the former top-level `/orchestration` tab. - value: 'orchestration', - label: t('brain.tabs.orchestration'), - icon: navIcon( - 'M12 7v3m0 0l-5.5 6M12 10l5.5 6M12 5a2 2 0 100 0M5 19a2 2 0 100 0M19 19a2 2 0 100 0' - ), - }, + // TinyPlace multi-agent orchestration, folded back under Brain + // from the former top-level `/orchestration` tab. Hidden from + // users without a tiny.place identity (#5424). + ...(hasTinyplaceIdentity + ? [ + { + value: 'orchestration', + label: t('brain.tabs.orchestration'), + icon: navIcon( + 'M12 7v3m0 0l-5.5 6M12 10l5.5 6M12 5a2 2 0 100 0M5 19a2 2 0 100 0M19 19a2 2 0 100 0' + ), + }, + ] + : []), ], }, ]} @@ -251,8 +274,11 @@ export default function Brain() { // Full-bleed: OrchestrationView renders its own chip nav + surfaces // (chat, graph, task board), which need the full content width — so it // sits outside the shared max-w scaffold the other tabs use. -
- +
+ +
+ +
) : (
diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index c81e7f5cef..9c3de6bbac 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -1,9 +1,15 @@ import { act, screen, waitFor } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '../../test/test-utils'; import Brain from '../Brain'; +/** Renders the live router pathname so redirect tests can assert the final route. */ +function LocationProbe() { + return
{useLocation().pathname}
; +} + const graphExportMock = vi.hoisted(() => vi.fn()); // Controllable authenticated identity so we can simulate a logout→login cycle // (userId null → set) and assert the graph reloads (#4149). @@ -11,12 +17,27 @@ const coreAuthRef = vi.hoisted(() => ({ current: 'user-A' as string | null })); // Captures navigate() calls so we can assert the legacy TinyPlace-orchestration // deep link bounces to the folded-in Orchestration sub-tab. const navigateSpy = vi.hoisted(() => vi.fn()); +// Fires on every OrchestrationView render, so a redirect test can prove the +// gated surface never mounted (not merely that it left the DOM afterwards). +const orchestrationRenderSpy = vi.hoisted(() => vi.fn()); +// Controllable tiny.place identity so we can render Brain as a holder (default) +// or a confirmed non-holder hitting the gated `?tab=orchestration` deep link. +const tinyplaceIdentityRef = vi.hoisted(() => ({ + current: { status: 'ready', hasIdentity: true } as { + status: 'loading' | 'ready'; + hasIdentity: boolean; + }, +})); vi.mock('react-router-dom', async importOriginal => { const actual = await importOriginal(); return { ...actual, useNavigate: () => navigateSpy }; }); +vi.mock('../../hooks/useTinyPlaceIdentity', () => ({ + useTinyPlaceIdentity: () => tinyplaceIdentityRef.current, +})); + vi.mock('../../utils/tauriCommands', () => ({ memoryTreeGraphExport: graphExportMock, isTauri: () => false, @@ -64,7 +85,12 @@ vi.mock('../../components/layout/ChipTabs', async () => { vi.mock('../../components/ui/BetaBanner', () => ({ default: () => null })); vi.mock('../../components/orchestration/OrchestrationView', async () => { const React = await import('react'); - return { default: () => React.createElement('div', { 'data-testid': 'brain-orchestration' }) }; + return { + default: () => { + orchestrationRenderSpy(); + return React.createElement('div', { 'data-testid': 'brain-orchestration' }); + }, + }; }); vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null })); @@ -98,6 +124,7 @@ describe('Brain page', () => { beforeEach(() => { vi.clearAllMocks(); coreAuthRef.current = 'user-A'; + tinyplaceIdentityRef.current = { status: 'ready', hasIdentity: true }; }); afterEach(() => { @@ -208,6 +235,43 @@ describe('Brain page', () => { expect(graphExportMock).not.toHaveBeenCalled(); }); + it('redirects a confirmed non-holder away from ?tab=orchestration without mounting OrchestrationView', async () => { + // #5424 — the redirect is render-phase, so the gated tiny.place surface must + // never mount (not even for a single commit) once the identity check + // resolves to a non-holder. + tinyplaceIdentityRef.current = { status: 'ready', hasIdentity: false }; + graphExportMock.mockResolvedValue(makeGraph(0)); + await act(async () => { + renderWithProviders( + <> + + + , + { initialEntries: ['/?tab=orchestration'] } + ); + }); + // Render-phase guard: OrchestrationView must never mount — asserting on the + // spy (not just final DOM) catches a regression that mounts then redirects + // from an effect. + expect(orchestrationRenderSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId('brain-orchestration')).not.toBeInTheDocument(); + // Router landed on the Brain welcome tab. + expect(screen.getByTestId('location-pathname')).toHaveTextContent('/brain'); + expect(screen.getByTestId('brain-welcome')).toBeInTheDocument(); + }); + + it('keeps rendering OrchestrationView while the identity check is still loading', async () => { + // The in-flight window must not redirect — only a *confirmed* non-holder does. + tinyplaceIdentityRef.current = { status: 'loading', hasIdentity: false }; + graphExportMock.mockResolvedValue(makeGraph(0)); + await act(async () => { + renderWithProviders(, { initialEntries: ['/?tab=orchestration'] }); + }); + await waitFor(() => { + expect(screen.getByTestId('brain-orchestration')).toBeInTheDocument(); + }); + }); + it('redirects the legacy tinyplace-orchestration deep link to the orchestration tab', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { diff --git a/app/src/utils/links.ts b/app/src/utils/links.ts index ae749191d5..9434eefdc1 100644 --- a/app/src/utils/links.ts +++ b/app/src/utils/links.ts @@ -1,5 +1,8 @@ export const DISCORD_INVITE_URL = 'https://discord.tinyhumans.ai'; export const PRICING_URL = 'https://tinyhumans.ai/pricing'; export const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard'; +/** tiny.place — the agent network. Kept working outside the app after the + * in-app surface is removed (#5424); the sunset notice links here. */ +export const TINYPLACE_URL = 'https://tiny.place'; export const PRIVACY_POLICY_URL = 'https://tinyhumans.gitbook.io/openhuman/legal/privacy-policy'; export const TERMS_OF_USE_URL = 'https://tinyhumans.gitbook.io/openhuman/legal/terms-of-use';