-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat: show tiny.place only to users with an identity #5439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e7dc11c
21017f7
d4eb8ec
f4cc35d
a005cd0
2e90d8b
bc55c6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { MemoryRouter, Route, Routes } from 'react-router-dom'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { TinyPlaceIdentityState } from '../hooks/useTinyPlaceIdentity'; | ||
| import AgentWorldShell from './AgentWorldShell'; | ||
|
|
||
| let identity: TinyPlaceIdentityState = { status: 'ready', hasIdentity: true }; | ||
| vi.mock('../hooks/useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identity })); | ||
| vi.mock('./TinyPlaceSunsetNotice', () => ({ | ||
| default: () => <div data-testid="tinyplace-sunset-notice" />, | ||
| })); | ||
| vi.mock('../lib/agentworld/invokeApiClient', () => ({ createInvokeApiClient: () => ({}) })); | ||
|
|
||
| function renderShell() { | ||
| return render( | ||
| <MemoryRouter initialEntries={['/agent-world']}> | ||
| <Routes> | ||
| <Route | ||
| path="/agent-world" | ||
| element={ | ||
| <AgentWorldShell> | ||
| <div data-testid="agent-world-content" /> | ||
| </AgentWorldShell> | ||
| } | ||
| /> | ||
| <Route path="/chat" element={<div data-testid="chat-page" />} /> | ||
| </Routes> | ||
| </MemoryRouter> | ||
| ); | ||
| } | ||
|
|
||
| describe('AgentWorldShell tiny.place gate (#5424)', () => { | ||
| beforeEach(() => { | ||
| identity = { status: 'ready', hasIdentity: true }; | ||
| }); | ||
|
|
||
| it('renders the agent-world surface and the notice for an identity holder', () => { | ||
| identity = { status: 'ready', hasIdentity: true }; | ||
| renderShell(); | ||
|
|
||
| expect(screen.getByTestId('agent-world-content')).toBeInTheDocument(); | ||
| expect(screen.getByTestId('tinyplace-sunset-notice')).toBeInTheDocument(); | ||
| expect(screen.queryByTestId('chat-page')).toBeNull(); | ||
| }); | ||
|
|
||
| it('redirects a confirmed non-holder away to chat', () => { | ||
| identity = { status: 'ready', hasIdentity: false }; | ||
| renderShell(); | ||
|
|
||
| expect(screen.getByTestId('chat-page')).toBeInTheDocument(); | ||
| expect(screen.queryByTestId('agent-world-content')).toBeNull(); | ||
| }); | ||
|
|
||
| it('renders optimistically while the identity check is still loading', () => { | ||
| identity = { status: 'loading', hasIdentity: false }; | ||
| renderShell(); | ||
|
|
||
| // A holder must not see a flash-then-redirect, so nothing redirects until | ||
| // the check confirms the user has no identity. | ||
| expect(screen.getByTestId('agent-world-content')).toBeInTheDocument(); | ||
| expect(screen.queryByTestId('chat-page')).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { fireEvent, render, screen } from '@testing-library/react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import TinyPlaceSunsetNotice from './TinyPlaceSunsetNotice'; | ||
|
|
||
| const openUrl = vi.fn(); | ||
| vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); | ||
| vi.mock('../utils/openUrl', () => ({ openUrl: (url: string) => openUrl(url) })); | ||
|
|
||
| describe('TinyPlaceSunsetNotice (#5424)', () => { | ||
| it('renders the removal notice with a call to action', () => { | ||
| render(<TinyPlaceSunsetNotice />); | ||
|
|
||
| expect(screen.getByTestId('tinyplace-sunset-notice')).toBeInTheDocument(); | ||
| expect(screen.getByText('tinyplaceSunset.title')).toBeInTheDocument(); | ||
| expect(screen.getByRole('button', { name: 'tinyplaceSunset.cta' })).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('opens tiny.place in the system browser when the CTA is clicked', () => { | ||
| render(<TinyPlaceSunsetNotice />); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: 'tinyplaceSunset.cta' })); | ||
| expect(openUrl).toHaveBeenCalledWith('https://tiny.place'); | ||
| }); | ||
|
|
||
| it('is not dismissible — no dismiss control is rendered', () => { | ||
| render(<TinyPlaceSunsetNotice />); | ||
|
|
||
| expect(screen.queryByRole('button', { name: 'common.dismiss' })).toBeNull(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick — this assertion is vacuous.
Either assert the prop directly, or make the control case real: // prove the query would find a dismiss button when one exists,
// so its absence in TinyPlaceSunsetNotice means something
it('is not dismissible — no dismiss control is rendered', () => {
render(<TinyPlaceSunsetNotice />);
expect(screen.queryByRole('button', { name: 'common.dismiss' })).toBeNull();
// control: the same query does find one when the banner is dismissible
render(<UpsellBanner variant="info" title="t" message="m" dismissible onDismiss={() => {}} />);
expect(screen.getByRole('button', { name: 'common.dismiss' })).toBeInTheDocument();
}); |
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| /** | ||
| * tiny.place removal notice (#5424). | ||
| * | ||
| * Shown on the tiny.place surfaces (Agent World + the Brain orchestration | ||
| * sub-tab) to users who have an identity — the only people who still see the | ||
| * feature. It tells them to keep using tiny.place at tiny.place, names the | ||
| * 31 August 2026 in-app removal date, and links out. Non-dismissible: the | ||
| * deadline is fixed, so the notice stays until then. | ||
| */ | ||
| import UpsellBanner from '../components/upsell/UpsellBanner'; | ||
| import { useT } from '../lib/i18n/I18nContext'; | ||
| import { TINYPLACE_URL } from '../utils/links'; | ||
| import { openUrl } from '../utils/openUrl'; | ||
|
|
||
| export default function TinyPlaceSunsetNotice() { | ||
| const { t } = useT(); | ||
|
|
||
| return ( | ||
| <div className="relative z-20" data-testid="tinyplace-sunset-notice"> | ||
| <UpsellBanner | ||
| variant="info" | ||
| title={t('tinyplaceSunset.title')} | ||
| message={t('tinyplaceSunset.message')} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 suggestion —
Suggested change — add an opt-out and use it here: // UpsellBanner.tsx
- <p className={`text-xs ${styles.text} truncate`}>{message}</p>
+ <p className={`text-xs ${styles.text} ${wrapMessage ? '' : 'truncate'}`}>{message}</p>
// TinyPlaceSunsetNotice.tsx
<UpsellBanner variant="info" … rounded={false} dismissible={false}
+ wrapMessageAlso worth |
||
| ctaLabel={t('tinyplaceSunset.cta')} | ||
| rounded={false} | ||
| dismissible={false} | ||
| onCtaClick={() => { | ||
| void openUrl(TINYPLACE_URL); | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| import { useMemo } from 'react'; | ||
| import { useLocation, useNavigate } from 'react-router-dom'; | ||
|
|
||
| import { NAV_TABS, type NavTab } from '../../../config/navConfig'; | ||
| import { type NavTab } from '../../../config/navConfig'; | ||
| import { useNavTabs } from '../../../hooks/useNavTabs'; | ||
| import { useT } from '../../../lib/i18n/I18nContext'; | ||
| import { trackEvent } from '../../../services/analytics'; | ||
| import { setActiveAccount } from '../../../store/accountsSlice'; | ||
|
|
@@ -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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 suggestion — the onboarding walkthrough still points at this now-conditional tab.
It doesn't hang: react-joyride logs Suggested change — make the step list identity-aware the same way the nav now is: // AppWalkthrough.tsx
- const steps = useMemo(() => createWalkthroughSteps(navigate, t), [navigate, t]);
+ const { hasIdentity } = useTinyPlaceIdentity();
+ const steps = useMemo(
+ () => createWalkthroughSteps(navigate, t).filter(
+ s => hasIdentity || s.target !== '[data-walkthrough="tab-agent-world"]'
+ ),
+ [navigate, t, hasIdentity]
+ ); |
||
| const tabs = useMemo( | ||
| () => navTabs.map(tab => ({ ...tab, label: t(tab.labelKey) })), | ||
| [navTabs, t] | ||
| ); | ||
| const activeTab = tabs.find(tab => matchActive(tab.path, location.pathname)); | ||
|
|
||
| const handleClick = (tab: NavTab, active: boolean) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { useNavTabs } from './useNavTabs'; | ||
| import type { TinyPlaceIdentityState } from './useTinyPlaceIdentity'; | ||
|
|
||
| // Hoisted so the vi.mock factory can legally reference it (the repo convention | ||
| // for controllable mock state — see Brain.test.tsx). | ||
| const identityRef = vi.hoisted(() => ({ | ||
| current: { status: 'ready', hasIdentity: false } as TinyPlaceIdentityState, | ||
| })); | ||
| vi.mock('./useTinyPlaceIdentity', () => ({ useTinyPlaceIdentity: () => identityRef.current })); | ||
|
|
||
| describe('useNavTabs (#5424)', () => { | ||
| beforeEach(() => { | ||
| identityRef.current = { status: 'ready', hasIdentity: false }; | ||
| }); | ||
|
|
||
| it('hides the agent-world (tiny.place) tab when the user has no identity', () => { | ||
| identityRef.current = { status: 'ready', hasIdentity: false }; | ||
| const { result } = renderHook(() => useNavTabs()); | ||
|
|
||
| expect(result.current.some(tab => tab.id === 'agent-world')).toBe(false); | ||
| // The other primary tabs are untouched. | ||
| expect(result.current.some(tab => tab.id === 'chat')).toBe(true); | ||
| }); | ||
|
|
||
| it('shows the agent-world tab for a user with a tiny.place identity', () => { | ||
| identityRef.current = { status: 'ready', hasIdentity: true }; | ||
| const { result } = renderHook(() => useNavTabs()); | ||
|
|
||
| expect(result.current.some(tab => tab.id === 'agent-world')).toBe(true); | ||
| }); | ||
|
|
||
| it('keeps the tab hidden while the identity check is still loading', () => { | ||
| identityRef.current = { status: 'loading', hasIdentity: false }; | ||
| const { result } = renderHook(() => useNavTabs()); | ||
|
|
||
| expect(result.current.some(tab => tab.id === 'agent-world')).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /** | ||
| * The visible primary nav tabs (#5424). | ||
| * | ||
| * Identical to {@link NAV_TABS} except the `agent-world` (tiny.place) tab is | ||
| * hidden from users without a tiny.place identity — the feature is being removed | ||
| * after 31 August 2026 and its entry points must only appear for people who | ||
| * already have one. Both nav renderers (expanded {@link SidebarNav} and the | ||
| * collapsed rail) consume this so the rule lives in one place. | ||
| */ | ||
| import { useMemo } from 'react'; | ||
|
|
||
| import { NAV_TABS, type NavTab } from '../config/navConfig'; | ||
| import { useTinyPlaceIdentity } from './useTinyPlaceIdentity'; | ||
|
|
||
| export function useNavTabs(): NavTab[] { | ||
| const { hasIdentity } = useTinyPlaceIdentity(); | ||
| return useMemo( | ||
| () => NAV_TABS.filter(tab => tab.id !== 'agent-world' || hasIdentity), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Six doors are gated by this PR (nav ×2,
Net effect after this PR: a user with no tiny.place identity has the tab hidden, the route bounced and the Brain chip removed — and can still switch on an autonomous tiny.place agent from Settings, for a surface they cannot open and that is being removed. (The panel isn't in this diff, which is presumably how it slipped.) Suggested change: // AgentAccessPanel.tsx
+ import { useTinyPlaceIdentity } from '../../../hooks/useTinyPlaceIdentity';
+ const { hasIdentity: hasTinyplaceIdentity } = useTinyPlaceIdentity();
- {autopilotJobId && (
+ {autopilotJobId && hasTinyplaceIdentity && (
<SettingsSection title={t('settings.agentAccess.tinyplaceAutopilot.title')} …>Plus a two-branch test in the panel spec, mirroring |
||
| [hasIdentity] | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The backoff added in
d4eb8ecefixed the nav-tab half of the transient-failure problem, not the routing half. For a real holder whose wallet is locked at startup (the motivating case in the hook's own doc comment):/agent-world(bookmark, deep link, restored window).status === 'loading'→ renders optimistically. ✅publish({ status: 'ready', hasIdentity: false }).<Navigate to="/chat" replace />— and because it isreplace, the/agent-worldhistory entry is gone.hasIdentity: true. The nav tab reappears; their location does not.So
useTinyPlaceIdentity.ts:18-19("a one-time startup failure never locks a holder out until an app restart") holds for the entry point but not for where the user actually was.Brain.tsx:199has the same behaviour for?tab=orchestration.Suggested change — give a transient error its own state, so gates that hide still hide but gates that evict stay permissive:
Neither this file nor
Brain.tsxthen needs a change — both already teststatus === 'ready', so'error'falls through to rendering — anduseNavTabskeeps hiding onhasIdentity === false. Worth adding a{ status: 'error', hasIdentity: false }case toAgentWorldShell.test.tsxasserting no redirect.