From e7dc11cedcc08c25c274d597ea1966f063c4f750 Mon Sep 17 00:00:00 2001 From: shanu Date: Fri, 7 Aug 2026 14:54:04 +0530 Subject: [PATCH 1/6] feat(agent-world): show tiny.place only to users with an identity tiny.place is being removed from the app after 31 August 2026. Show its entry points only to users who already set up a tiny.place identity and give them a persistent notice pointing to tiny.place. - Add useTinyPlaceIdentity (cached self-identity check) and useNavTabs, which hides the agent-world tab for users without an identity in both nav renderers. - Guard the agent-world route (redirect confirmed non-holders to chat, direct links preserved for holders) and hide the Brain orchestration sub-tab. - Add a non-dismissable TinyPlaceSunsetNotice with a link out to tiny.place on the tiny.place surfaces. - Add tinyplaceSunset.* copy to every supported locale. Closes #5424 --- app/src/agentworld/AgentWorldShell.test.tsx | 64 ++++++++++++++++ app/src/agentworld/AgentWorldShell.tsx | 20 ++++- .../agentworld/TinyPlaceSunsetNotice.test.tsx | 31 ++++++++ app/src/agentworld/TinyPlaceSunsetNotice.tsx | 33 ++++++++ .../layout/shell/CollapsedNavRail.tsx | 9 ++- .../components/layout/shell/SidebarNav.tsx | 9 ++- app/src/hooks/useNavTabs.test.ts | 37 +++++++++ app/src/hooks/useNavTabs.ts | 21 +++++ app/src/hooks/useTinyPlaceIdentity.test.ts | 51 +++++++++++++ app/src/hooks/useTinyPlaceIdentity.ts | 76 +++++++++++++++++++ app/src/lib/i18n/ar.ts | 4 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 4 + app/src/lib/i18n/fr.ts | 4 + app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 4 + app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 4 + app/src/lib/i18n/ru.ts | 4 + app/src/lib/i18n/zh-CN.ts | 4 + app/src/pages/Brain.tsx | 44 ++++++++--- app/src/utils/links.ts | 3 + 26 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 app/src/agentworld/AgentWorldShell.test.tsx create mode 100644 app/src/agentworld/TinyPlaceSunsetNotice.test.tsx create mode 100644 app/src/agentworld/TinyPlaceSunsetNotice.tsx create mode 100644 app/src/hooks/useNavTabs.test.ts create mode 100644 app/src/hooks/useNavTabs.ts create mode 100644 app/src/hooks/useTinyPlaceIdentity.test.ts create mode 100644 app/src/hooks/useTinyPlaceIdentity.ts 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.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.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..d24787961e --- /dev/null +++ b/app/src/hooks/useNavTabs.test.ts @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useNavTabs } from './useNavTabs'; +import type { TinyPlaceIdentityState } from './useTinyPlaceIdentity'; + +let identity: TinyPlaceIdentityState = { status: 'ready', hasIdentity: false }; +vi.mock('./useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identity })); + +describe('useNavTabs (#5424)', () => { + beforeEach(() => { + identity = { status: 'ready', hasIdentity: false }; + }); + + it('hides the agent-world (tiny.place) tab when the user has no identity', () => { + identity = { 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', () => { + identity = { 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', () => { + identity = { 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..6c30bdb936 --- /dev/null +++ b/app/src/hooks/useTinyPlaceIdentity.test.ts @@ -0,0 +1,51 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { 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(); + }); + + 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('fails closed (no identity) when the RPC rejects — e.g. a locked wallet', 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 RPC backs every consumer for the app session. + expect(selfIdentity).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/hooks/useTinyPlaceIdentity.ts b/app/src/hooks/useTinyPlaceIdentity.ts new file mode 100644 index 0000000000..3596d6fe18 --- /dev/null +++ b/app/src/hooks/useTinyPlaceIdentity.ts @@ -0,0 +1,76 @@ +/** + * 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. The RPC can reject when the + * wallet is locked or unconfigured — that is exactly the "never set up" case, so + * a rejection resolves to `hasIdentity: false` (fail-closed → hidden). + * + * The result is fetched once per app session and cached 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 RPC rather than each firing + * their own. + */ +import { useEffect, useState } from 'react'; + +import { orchestrationClient } from '../lib/orchestration/orchestrationClient'; + +export interface TinyPlaceIdentityState { + /** `loading` until the one-shot RPC settles; `ready` once resolved. */ + status: 'loading' | 'ready'; + /** True only when a tiny.place identity exists for this user. */ + hasIdentity: boolean; +} + +let cache: TinyPlaceIdentityState = { status: 'loading', hasIdentity: false }; +let started = false; +const listeners = new Set<() => void>(); + +function publish(next: TinyPlaceIdentityState) { + cache = next; + listeners.forEach(listener => listener()); +} + +async function load() { + try { + const identity = await orchestrationClient.selfIdentity(); + publish({ status: 'ready', hasIdentity: identity.agentId.trim().length > 0 }); + } catch { + // Locked/unconfigured wallet or a degraded relay: treat as "no identity" so + // the entry points stay hidden rather than flashing in on a transient error. + publish({ status: 'ready', hasIdentity: false }); + } +} + +/** Test seam — clears the module cache so each test starts from `loading`. */ +export function __resetTinyPlaceIdentityForTests() { + cache = { status: 'loading', hasIdentity: false }; + started = false; + listeners.clear(); +} + +export function useTinyPlaceIdentity(): TinyPlaceIdentityState { + const [state, setState] = useState(cache); + + useEffect(() => { + const sync = () => setState(cache); + listeners.add(sync); + if (!started) { + started = true; + void load(); + } + // 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); + }; + }, []); + + return state; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 254fce9cf2..576554781f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7039,6 +7039,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 b1eca0499a..60cc1173ac 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7198,6 +7198,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 3a82a80417..f66a148114 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7401,6 +7401,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 aa9f760845..aebc28151a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7600,6 +7600,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 c6c6fa1530..5a86632b58 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7348,6 +7348,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 ab5b415407..20a109ab5e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7379,6 +7379,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 c3fce2d644..950f768a17 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7196,6 +7196,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 ee7d673ad6..be77bc3bdc 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7234,6 +7234,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 d4adccb572..15692a8f8c 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7331,6 +7331,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 9874b3b1be..04ddcf5e3b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7117,6 +7117,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 da4e8bb5ec..cf850ae590 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7303,6 +7303,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 c0c7551919..3871ef514c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7314,6 +7314,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 83f61f6272..18565b2c9b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7276,6 +7276,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 0355b01188..67ba50734d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6812,6 +6812,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..c853cad6f5 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { 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 one lands on `?tab=orchestration` via a stale deep + // link, send them to the Brain welcome tab once the identity check confirms + // they have none (holders and the in-flight window are left untouched). + const { status: tinyplaceStatus, hasIdentity: hasTinyplaceIdentity } = useTinyPlaceIdentity(); + useEffect(() => { + if (activeTab === 'orchestration' && tinyplaceStatus === 'ready' && !hasTinyplaceIdentity) { + console.debug('[brain] orchestration tab without tiny.place identity → welcome'); + navigate('/brain', { replace: true }); + } + }, [activeTab, tinyplaceStatus, hasTinyplaceIdentity, navigate]); + const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState('tree'); @@ -232,15 +246,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 +270,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/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'; From 21017f7a807a2b2b661547588d815b1c47ed8129 Mon Sep 17 00:00:00 2001 From: shanu Date: Fri, 7 Aug 2026 16:06:43 +0530 Subject: [PATCH 2/6] test(nav): pin tiny.place identity present in nav active-matching tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-world tab is now gated on a tiny.place identity (#5424), so the existing SidebarNav / CollapsedNavRail tests — which assert the full nav and Tiny.Place active state — must mock useTinyPlaceIdentity as present. The gate itself is covered by useNavTabs.test.ts. --- app/src/components/layout/shell/CollapsedNavRail.test.tsx | 6 ++++++ app/src/components/layout/shell/SidebarNav.test.tsx | 7 +++++++ 2 files changed, 13 insertions(+) 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/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; From d4eb8eceb88b0fc5bce3949346631e932457fbdd Mon Sep 17 00:00:00 2001 From: shanu Date: Fri, 7 Aug 2026 16:32:57 +0530 Subject: [PATCH 3/6] fix(agent-world): retry a failed tiny.place identity check instead of caching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected self_identity call (wallet/keyring locked at startup, or a transient relay error) previously published ready/false and latched, so a real identity holder stayed hidden from tiny.place — nav tab gone, direct links redirected — until an app restart. Distinguish a resolved answer (terminal) from a transient rejection: on rejection stay fail-closed but retry with a bounded backoff, and re-check immediately on window focus (e.g. after the user unlocks their wallet), so a one-time startup failure never locks a holder out for the session. --- app/src/hooks/useTinyPlaceIdentity.test.ts | 49 +++++++++++-- app/src/hooks/useTinyPlaceIdentity.ts | 81 +++++++++++++++++----- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/app/src/hooks/useTinyPlaceIdentity.test.ts b/app/src/hooks/useTinyPlaceIdentity.test.ts index 6c30bdb936..2ed4ec5490 100644 --- a/app/src/hooks/useTinyPlaceIdentity.test.ts +++ b/app/src/hooks/useTinyPlaceIdentity.test.ts @@ -1,5 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { __resetTinyPlaceIdentityForTests, useTinyPlaceIdentity } from './useTinyPlaceIdentity'; @@ -14,6 +14,11 @@ describe('useTinyPlaceIdentity (#5424)', () => { __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()); @@ -30,7 +35,7 @@ describe('useTinyPlaceIdentity (#5424)', () => { expect(result.current.hasIdentity).toBe(false); }); - it('fails closed (no identity) when the RPC rejects — e.g. a locked wallet', async () => { + it('stays fail-closed (hidden) while a rejected check is retried', async () => { selfIdentity.mockRejectedValue(new Error('wallet locked')); const { result } = renderHook(() => useTinyPlaceIdentity()); @@ -45,7 +50,43 @@ describe('useTinyPlaceIdentity (#5424)', () => { await waitFor(() => expect(first.result.current.status).toBe('ready')); expect(second.result.current.hasIdentity).toBe(true); - // A single RPC backs every consumer for the app session. + // 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 index 3596d6fe18..7205f7b187 100644 --- a/app/src/hooks/useTinyPlaceIdentity.ts +++ b/app/src/hooks/useTinyPlaceIdentity.ts @@ -7,28 +7,39 @@ * 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. The RPC can reject when the - * wallet is locked or unconfigured — that is exactly the "never set up" case, so - * a rejection resolves to `hasIdentity: false` (fail-closed → hidden). + * `agentId` means the wallet-backed identity exists. * - * The result is fetched once per app session and cached 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 RPC rather than each firing - * their own. + * 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 one-shot RPC settles; `ready` once resolved. */ + /** `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 started = 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) { @@ -36,21 +47,47 @@ function publish(next: TinyPlaceIdentityState) { listeners.forEach(listener => listener()); } -async function load() { +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 { - // Locked/unconfigured wallet or a degraded relay: treat as "no identity" so - // the entry points stay hidden rather than flashing in on a transient error. + // 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 }; - started = false; + resolved = false; + inFlight = false; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } listeners.clear(); } @@ -60,15 +97,25 @@ export function useTinyPlaceIdentity(): TinyPlaceIdentityState { useEffect(() => { const sync = () => setState(cache); listeners.add(sync); - if (!started) { - started = true; - void load(); - } + 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); }; }, []); From a005cd0213a1a94cedfc8bad0639ad5eb09ff894 Mon Sep 17 00:00:00 2001 From: shanu Date: Mon, 10 Aug 2026 18:45:14 +0530 Subject: [PATCH 4/6] fix(agent-world): gate orchestration deep link at render time + product-name notice title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #5439. - Brain: redirect a confirmed non-holder away from ?tab=orchestration in the render phase (return ) instead of a post-commit effect, so OrchestrationView — and its tiny.place RPCs — never mount even once for a gated user. The in-flight 'loading' window and holders are untouched. Adds Brain tests for the confirmed-non-holder redirect and the loading window. - i18n: the sunset notice title is a UI product label, so use the product name 'Tiny Place' (matching nav.agentWorld / agentWorld.world.title) across all 14 locales; the message and CTA keep the 'tiny.place' domain spelling. CodeRabbit flagged only bn/es/id — applied consistently to every locale, English source included. --- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 2 +- app/src/pages/Brain.tsx | 21 ++++++++------ app/src/pages/__tests__/Brain.test.tsx | 39 ++++++++++++++++++++++++++ 16 files changed, 65 insertions(+), 23 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 3c37ed8700..728491b8c8 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7034,7 +7034,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'انتهت ميزانية التضمينات لديك، لذلك لم يعد المحتوى الجديد يُضاف إلى الذاكرة. أعدّ تضمينات محلية أو أضف مفتاح API الخاص بك للمتابعة.', 'memoryBudget.cta': 'إعداد التضمينات', - 'tinyplaceSunset.title': 'ينتقل tiny.place خارج التطبيق', + 'tinyplaceSunset.title': 'ينتقل Tiny Place خارج التطبيق', 'tinyplaceSunset.message': 'لمواصلة استخدام tiny.place، زر tiny.place. ستتم إزالته من التطبيق بعد 31 أغسطس 2026.', 'tinyplaceSunset.cta': 'فتح tiny.place', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 454d51cb18..4c0522089b 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7190,7 +7190,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'আপনার এমবেডিং বাজেট শেষ, তাই নতুন কনটেন্ট আর মেমরিতে যুক্ত হচ্ছে না। আবার শুরু করতে লোকাল এমবেডিং সেট আপ করুন বা নিজের API কী যোগ করুন।', 'memoryBudget.cta': 'এমবেডিং সেট আপ করুন', - 'tinyplaceSunset.title': 'tiny.place অ্যাপ থেকে সরে যাচ্ছে', + 'tinyplaceSunset.title': 'Tiny Place অ্যাপ থেকে সরে যাচ্ছে', 'tinyplaceSunset.message': 'tiny.place ব্যবহার চালিয়ে যেতে tiny.place-এ যান। ৩১ আগস্ট ২০২৬-এর পর এটি অ্যাপ থেকে সরিয়ে ফেলা হবে।', 'tinyplaceSunset.cta': 'tiny.place খুলুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 1e7be0e59e..ab606eb7b3 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7394,7 +7394,7 @@ 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.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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 325528b734..6a7986e99c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7593,7 +7593,7 @@ 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.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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f128dc78e1..7c7d5df858 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7341,7 +7341,7 @@ 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.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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 73808f536a..2be7397644 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7372,7 +7372,7 @@ 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.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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 324c557629..0254068738 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7189,7 +7189,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'आपका एम्बेडिंग बजट खत्म हो गया है, इसलिए नई सामग्री अब मेमोरी में नहीं जुड़ रही। दोबारा शुरू करने के लिए लोकल एम्बेडिंग सेट करें या अपनी API कुंजी जोड़ें।', 'memoryBudget.cta': 'एम्बेडिंग सेट करें', - 'tinyplaceSunset.title': 'tiny.place ऐप से बाहर जा रहा है', + 'tinyplaceSunset.title': 'Tiny Place ऐप से बाहर जा रहा है', 'tinyplaceSunset.message': 'tiny.place का उपयोग जारी रखने के लिए tiny.place पर जाएं। 31 अगस्त 2026 के बाद इसे ऐप से हटा दिया जाएगा।', 'tinyplaceSunset.cta': 'tiny.place खोलें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ac30e81a70..a9f93f83bf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7229,7 +7229,7 @@ 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.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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b27222a131..5e9a6aa550 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7324,7 +7324,7 @@ 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.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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 744056b94c..20bc1e1919 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7110,7 +7110,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': '임베딩 예산을 모두 사용해 새 콘텐츠가 메모리에 추가되지 않습니다. 로컬 임베딩을 설정하거나 본인의 API 키를 추가하면 다시 시작됩니다.', 'memoryBudget.cta': '임베딩 설정', - 'tinyplaceSunset.title': 'tiny.place가 앱에서 분리됩니다', + 'tinyplaceSunset.title': 'Tiny Place가 앱에서 분리됩니다', 'tinyplaceSunset.message': 'tiny.place를 계속 사용하려면 tiny.place에서 이용하세요. 2026년 8월 31일 이후 앱에서 제거됩니다.', 'tinyplaceSunset.cta': 'tiny.place 열기', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 12ea57e525..9c92192a8f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7295,7 +7295,7 @@ 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.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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index a9d629dae4..8fb2c40bea 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7308,7 +7308,7 @@ 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.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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f2f1bbd861..521a9aef19 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7269,7 +7269,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Бюджет эмбеддингов израсходован, поэтому новые данные больше не добавляются в память. Настройте локальные эмбеддинги или добавьте свой ключ API, чтобы продолжить.', 'memoryBudget.cta': 'Настроить эмбеддинги', - 'tinyplaceSunset.title': 'tiny.place уходит из приложения', + 'tinyplaceSunset.title': 'Tiny Place уходит из приложения', 'tinyplaceSunset.message': 'Чтобы продолжить пользоваться tiny.place, откройте tiny.place. После 31 августа 2026 года он будет удалён из приложения.', 'tinyplaceSunset.cta': 'Открыть tiny.place', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index bcae7caa31..e82ac2b93f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6806,7 +6806,7 @@ const messages: TranslationMap = { 'memoryBudget.exhaustedMessage': '你的嵌入额度已用尽,新内容不会再加入记忆。设置本地嵌入或添加你自己的 API 密钥即可恢复。', 'memoryBudget.cta': '设置嵌入', - 'tinyplaceSunset.title': 'tiny.place 即将移出应用', + 'tinyplaceSunset.title': 'Tiny Place 即将移出应用', 'tinyplaceSunset.message': '若要继续使用 tiny.place,请前往 tiny.place。2026 年 8 月 31 日后它将从应用中移除。', 'tinyplaceSunset.cta': '打开 tiny.place', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index c853cad6f5..ee47b1f00a 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -6,7 +6,7 @@ * 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'; @@ -109,15 +109,14 @@ export default function Brain() { // #5424 — the Orchestration sub-tab is a tiny.place surface, hidden from users // without an identity. If one lands on `?tab=orchestration` via a stale deep - // link, send them to the Brain welcome tab once the identity check confirms - // they have none (holders and the in-flight window are left untouched). + // link, redirect to the Brain welcome tab once the identity check confirms + // they have none. This is a render-phase redirect (not a post-commit effect) + // so OrchestrationView never mounts for a confirmed non-holder — its RPCs and + // tiny.place surface must not fire even once. Holders and the in-flight + // `loading` window are left untouched. const { status: tinyplaceStatus, hasIdentity: hasTinyplaceIdentity } = useTinyPlaceIdentity(); - useEffect(() => { - if (activeTab === 'orchestration' && tinyplaceStatus === 'ready' && !hasTinyplaceIdentity) { - console.debug('[brain] orchestration tab without tiny.place identity → welcome'); - navigate('/brain', { replace: true }); - } - }, [activeTab, tinyplaceStatus, hasTinyplaceIdentity, navigate]); + const shouldRedirectFromOrchestration = + activeTab === 'orchestration' && tinyplaceStatus === 'ready' && !hasTinyplaceIdentity; const [graph, setGraph] = useState(null); const [error, setError] = useState(null); @@ -196,6 +195,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. */} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index c81e7f5cef..9a4a7007c5 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -11,12 +11,24 @@ 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()); +// 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, @@ -98,6 +110,7 @@ describe('Brain page', () => { beforeEach(() => { vi.clearAllMocks(); coreAuthRef.current = 'user-A'; + tinyplaceIdentityRef.current = { status: 'ready', hasIdentity: true }; }); afterEach(() => { @@ -208,6 +221,32 @@ 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'] }); + }); + expect(screen.queryByTestId('brain-orchestration')).not.toBeInTheDocument(); + // Landed on the Brain welcome tab instead. + 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 () => { From 2e90d8b48bb4548a34e1e45d799d3fc4381d5f81 Mon Sep 17 00:00:00 2001 From: shanu Date: Mon, 10 Aug 2026 19:03:30 +0530 Subject: [PATCH 5/6] test(brain): prove OrchestrationView never mounts + router lands on /brain Address CodeRabbit follow-up on #5439: queryByTestId only checks final DOM, so a regression that mounts OrchestrationView and redirects from an effect would still pass. Add a hoisted render spy on OrchestrationView (assert not called) and a useLocation pathname probe (assert final route is /brain) to the confirmed non-holder test, alongside the existing welcome-tab assertion. --- app/src/pages/__tests__/Brain.test.tsx | 31 +++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 9a4a7007c5..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,6 +17,9 @@ 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(() => ({ @@ -76,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 })); @@ -228,10 +242,21 @@ describe('Brain page', () => { tinyplaceIdentityRef.current = { status: 'ready', hasIdentity: false }; graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { - renderWithProviders(, { initialEntries: ['/?tab=orchestration'] }); + 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(); - // Landed on the Brain welcome tab instead. + // Router landed on the Brain welcome tab. + expect(screen.getByTestId('location-pathname')).toHaveTextContent('/brain'); expect(screen.getByTestId('brain-welcome')).toBeInTheDocument(); }); From bc55c6d23f89ccd72f3bbccb9b85eb93c20b0a61 Mon Sep 17 00:00:00 2001 From: shanu Date: Mon, 10 Aug 2026 19:52:44 +0530 Subject: [PATCH 6/6] test(nav): use vi.hoisted mock ref; clarify Brain orchestration loading comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address tinysweeper review on #5439. - useNavTabs.test.ts: switch the controllable identity mock to vi.hoisted (the repo convention, as in Brain.test.tsx). The prior module-scoped `let identity` already passed (Vitest v4 evaluates the factory's `() => identity` lazily at call time, not at hoist time), but the hoisted ref matches the established pattern and removes any ambiguity. - Brain.tsx: the orchestration redirect comment overstated the invariant. A *confirmed* non-holder never mounts OrchestrationView, but the in-flight `loading` window renders optimistically — matching the AgentWorldShell route guard so a holder never sees a flash. Reworded to describe that trade-off accurately; behaviour is unchanged and intentional. --- app/src/hooks/useNavTabs.test.ts | 16 ++++++++++------ app/src/pages/Brain.tsx | 13 +++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/src/hooks/useNavTabs.test.ts b/app/src/hooks/useNavTabs.test.ts index d24787961e..3fcaf8129b 100644 --- a/app/src/hooks/useNavTabs.test.ts +++ b/app/src/hooks/useNavTabs.test.ts @@ -4,16 +4,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useNavTabs } from './useNavTabs'; import type { TinyPlaceIdentityState } from './useTinyPlaceIdentity'; -let identity: TinyPlaceIdentityState = { status: 'ready', hasIdentity: false }; -vi.mock('./useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identity })); +// 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(() => { - identity = { status: 'ready', hasIdentity: false }; + identityRef.current = { status: 'ready', hasIdentity: false }; }); it('hides the agent-world (tiny.place) tab when the user has no identity', () => { - identity = { status: 'ready', hasIdentity: false }; + identityRef.current = { status: 'ready', hasIdentity: false }; const { result } = renderHook(() => useNavTabs()); expect(result.current.some(tab => tab.id === 'agent-world')).toBe(false); @@ -22,14 +26,14 @@ describe('useNavTabs (#5424)', () => { }); it('shows the agent-world tab for a user with a tiny.place identity', () => { - identity = { status: 'ready', hasIdentity: true }; + 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', () => { - identity = { status: 'loading', hasIdentity: false }; + 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/pages/Brain.tsx b/app/src/pages/Brain.tsx index ee47b1f00a..82214a4546 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -108,12 +108,13 @@ export default function Brain() { }, [location.search, navigate]); // #5424 — the Orchestration sub-tab is a tiny.place surface, hidden from users - // without an identity. If one lands on `?tab=orchestration` via a stale deep - // link, redirect to the Brain welcome tab once the identity check confirms - // they have none. This is a render-phase redirect (not a post-commit effect) - // so OrchestrationView never mounts for a confirmed non-holder — its RPCs and - // tiny.place surface must not fire even once. Holders and the in-flight - // `loading` window are left untouched. + // 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;